feat(discord): enable session join leave buttons
PR Checks / test-and-build (pull_request) Successful in 6m6s
PR Checks / test-and-build (pull_request) Successful in 6m6s
Move neutral join/leave handlers into GmRelay.Shared so Telegram and Discord share capacity, waitlist, duplicate-click, and schedule-update behavior. Add Discord component routing for join_session and leave_session buttons with deferred ephemeral replies and serialized schedule message updates. Bump version to 2.5.0 and update Discord docs. Refs #29
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
using System.Globalization;
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
|
||||
public sealed record JoinSessionCommand(
|
||||
Guid SessionId,
|
||||
PlatformUser User,
|
||||
string InteractionId,
|
||||
PlatformGroup Group,
|
||||
PlatformMessageRef ScheduleMessage);
|
||||
|
||||
// DTOs for AOT compilation
|
||||
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, string Status, int? MaxPlayers);
|
||||
|
||||
public sealed class JoinSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
IPlatformMessenger messenger,
|
||||
IScheduleMessageUpdateLock scheduleUpdateLock,
|
||||
ILogger<JoinSessionHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
var transactionCommitted = false;
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Убеждаемся, что игрок есть в базе
|
||||
var platform = command.User.Platform.ToString();
|
||||
var legacyTelegramId = command.User.Platform == PlatformKind.Telegram
|
||||
? long.Parse(command.User.ExternalUserId, CultureInfo.InvariantCulture)
|
||||
: (long?)null;
|
||||
var legacyTelegramUsername = command.User.Platform == PlatformKind.Telegram
|
||||
? command.User.ExternalUsername
|
||||
: null;
|
||||
|
||||
var playerId = await connection.ExecuteScalarAsync<Guid>(
|
||||
@"INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
|
||||
VALUES (@LegacyTelegramId, @Name, @LegacyTelegramUsername, @Platform, @ExternalUserId, @ExternalUsername)
|
||||
ON CONFLICT (platform, external_user_id)
|
||||
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
|
||||
DO UPDATE
|
||||
SET display_name = EXCLUDED.display_name,
|
||||
telegram_username = COALESCE(EXCLUDED.telegram_username, players.telegram_username),
|
||||
platform = EXCLUDED.platform,
|
||||
external_user_id = EXCLUDED.external_user_id,
|
||||
external_username = EXCLUDED.external_username
|
||||
RETURNING id;",
|
||||
new
|
||||
{
|
||||
LegacyTelegramId = legacyTelegramId,
|
||||
Name = command.User.DisplayName,
|
||||
LegacyTelegramUsername = legacyTelegramUsername,
|
||||
Platform = platform,
|
||||
command.User.ExternalUserId,
|
||||
command.User.ExternalUsername
|
||||
},
|
||||
transaction);
|
||||
|
||||
// 2. Блокируем сессию на время расчета мест, чтобы параллельные нажатия не переполнили состав.
|
||||
var batchInfo = await connection.QuerySingleOrDefaultAsync<JoinSessionBatchDto>(
|
||||
@"SELECT batch_id as BatchId, title as Title, status as Status, max_players as MaxPlayers
|
||||
FROM sessions
|
||||
WHERE id = @SessionId
|
||||
FOR UPDATE",
|
||||
new { command.SessionId },
|
||||
transaction);
|
||||
|
||||
if (batchInfo is null)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (SessionStatus.IsCancelled(batchInfo.Status))
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var existingRegistrationStatus = await connection.ExecuteScalarAsync<string?>(
|
||||
"""
|
||||
SELECT sp.registration_status
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND sp.player_id = @PlayerId
|
||||
AND sp.is_gm = false
|
||||
""",
|
||||
new { command.SessionId, PlayerId = playerId },
|
||||
transaction);
|
||||
|
||||
if (existingRegistrationStatus is not null)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||
? "Вы уже в листе ожидания!"
|
||||
: "Вы уже записаны!";
|
||||
await AnswerAsync(command.InteractionId, alreadyText, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var activeParticipants = await connection.ExecuteScalarAsync<int>(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM session_participants
|
||||
WHERE session_id = @SessionId
|
||||
AND is_gm = false
|
||||
AND registration_status = @Active
|
||||
""",
|
||||
new { command.SessionId, Active = ParticipantRegistrationStatus.Active },
|
||||
transaction);
|
||||
|
||||
var registrationStatus = SessionCapacityRules.DecideJoinStatus(batchInfo.MaxPlayers, activeParticipants);
|
||||
|
||||
// 3. Добавляем в основной состав или лист ожидания. RSVP остается Pending до финального подтверждения.
|
||||
var inserted = await connection.ExecuteAsync(
|
||||
@"INSERT INTO session_participants (session_id, player_id, is_gm, rsvp_status, registration_status)
|
||||
VALUES (@SessionId, @PlayerId, false, @Pending, @RegistrationStatus)
|
||||
ON CONFLICT (session_id, player_id) DO NOTHING;",
|
||||
new
|
||||
{
|
||||
command.SessionId,
|
||||
PlayerId = playerId,
|
||||
Pending = RsvpStatus.Pending,
|
||||
RegistrationStatus = registrationStatus
|
||||
},
|
||||
transaction);
|
||||
|
||||
if (inserted == 0)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Загружаем весь батч для перерисовки
|
||||
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
|
||||
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink
|
||||
FROM sessions
|
||||
WHERE batch_id = @BatchId
|
||||
ORDER BY scheduled_at",
|
||||
new { BatchId = batchInfo.BatchId }, transaction);
|
||||
|
||||
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
|
||||
@"SELECT sp.session_id as SessionId,
|
||||
p.display_name as DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) as TelegramUsername,
|
||||
sp.registration_status as RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON sp.player_id = p.id
|
||||
JOIN sessions s ON sp.session_id = s.id
|
||||
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
||||
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC",
|
||||
new { BatchId = batchInfo.BatchId }, transaction);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
transactionCommitted = true;
|
||||
|
||||
// 4. Перерисовываем сообщение
|
||||
var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
|
||||
await messenger.UpdateScheduleAsync(
|
||||
new PlatformScheduleMessage(
|
||||
command.Group,
|
||||
view,
|
||||
command.ScheduleMessage),
|
||||
ct);
|
||||
|
||||
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||
? "Основной состав заполнен. Вы добавлены в лист ожидания."
|
||||
: "Вы успешно записаны!";
|
||||
await AnswerAsync(command.InteractionId, callbackText, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Ошибка при добавлении игрока к сессии");
|
||||
if (!transactionCommitted)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
}
|
||||
|
||||
var errorText = transactionCommitted
|
||||
? "Регистрация сохранена, но не удалось обновить сообщение расписания."
|
||||
: "Произошла ошибка при регистрации.";
|
||||
await AnswerAsync(command.InteractionId, errorText, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
|
||||
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
|
||||
public sealed record LeaveSessionCommand(
|
||||
Guid SessionId,
|
||||
PlatformUser User,
|
||||
string InteractionId,
|
||||
PlatformGroup Group,
|
||||
PlatformMessageRef ScheduleMessage);
|
||||
|
||||
internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers);
|
||||
internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus);
|
||||
internal sealed record LeaveSessionPromotionDto(Guid ParticipantRowId, string DisplayName);
|
||||
|
||||
public sealed class LeaveSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
IPlatformMessenger messenger,
|
||||
IScheduleMessageUpdateLock scheduleUpdateLock,
|
||||
ILogger<LeaveSessionHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
var transactionCommitted = false;
|
||||
|
||||
try
|
||||
{
|
||||
var session = await connection.QuerySingleOrDefaultAsync<LeaveSessionInfoDto>(
|
||||
"""
|
||||
SELECT title AS Title,
|
||||
batch_id AS BatchId,
|
||||
status AS Status,
|
||||
max_players AS MaxPlayers
|
||||
FROM sessions
|
||||
WHERE id = @SessionId
|
||||
FOR UPDATE
|
||||
""",
|
||||
new { command.SessionId },
|
||||
transaction);
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (SessionStatus.IsCancelled(session.Status))
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var platform = command.User.Platform.ToString();
|
||||
|
||||
var participant = await connection.QuerySingleOrDefaultAsync<LeaveSessionParticipantDto>(
|
||||
"""
|
||||
SELECT sp.id AS ParticipantRowId,
|
||||
p.display_name AS DisplayName,
|
||||
sp.registration_status AS RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND p.platform = @Platform
|
||||
AND p.external_user_id = @ExternalUserId
|
||||
AND sp.is_gm = false
|
||||
FOR UPDATE OF sp
|
||||
""",
|
||||
new { command.SessionId, Platform = platform, command.User.ExternalUserId },
|
||||
transaction);
|
||||
|
||||
if (participant is null)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
DELETE FROM session_participants
|
||||
WHERE id = @ParticipantRowId
|
||||
""",
|
||||
new { participant.ParticipantRowId },
|
||||
transaction);
|
||||
|
||||
var activeParticipantsAfterLeave = await connection.ExecuteScalarAsync<int>(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM session_participants
|
||||
WHERE session_id = @SessionId
|
||||
AND is_gm = false
|
||||
AND registration_status = @Active
|
||||
""",
|
||||
new { command.SessionId, Active = ParticipantRegistrationStatus.Active },
|
||||
transaction);
|
||||
|
||||
var waitlistedParticipants = await connection.ExecuteScalarAsync<int>(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM session_participants
|
||||
WHERE session_id = @SessionId
|
||||
AND is_gm = false
|
||||
AND registration_status = @Waitlisted
|
||||
""",
|
||||
new { command.SessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
|
||||
transaction);
|
||||
|
||||
string? promotedDisplayName = null;
|
||||
if (SessionCapacityRules.ShouldPromoteAfterParticipantLeaves(
|
||||
participant.RegistrationStatus,
|
||||
session.MaxPlayers,
|
||||
activeParticipantsAfterLeave,
|
||||
waitlistedParticipants))
|
||||
{
|
||||
var promoted = await connection.QuerySingleAsync<LeaveSessionPromotionDto>(
|
||||
"""
|
||||
SELECT sp.id AS ParticipantRowId,
|
||||
p.display_name AS DisplayName
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Waitlisted
|
||||
ORDER BY sp.created_at ASC, sp.id ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE OF sp
|
||||
""",
|
||||
new { command.SessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
|
||||
transaction);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE session_participants
|
||||
SET registration_status = @Active,
|
||||
rsvp_status = @Pending,
|
||||
responded_at = NULL
|
||||
WHERE id = @ParticipantRowId
|
||||
""",
|
||||
new
|
||||
{
|
||||
promoted.ParticipantRowId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Pending = RsvpStatus.Pending
|
||||
},
|
||||
transaction);
|
||||
|
||||
promotedDisplayName = promoted.DisplayName;
|
||||
}
|
||||
|
||||
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
|
||||
"""
|
||||
SELECT id AS SessionId,
|
||||
scheduled_at AS ScheduledAt,
|
||||
status AS Status,
|
||||
max_players AS MaxPlayers,
|
||||
join_link AS JoinLink
|
||||
FROM sessions
|
||||
WHERE batch_id = @BatchId
|
||||
ORDER BY scheduled_at
|
||||
""",
|
||||
new { session.BatchId },
|
||||
transaction)).ToList();
|
||||
|
||||
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||||
"""
|
||||
SELECT sp.session_id AS SessionId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername,
|
||||
sp.registration_status AS RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON sp.player_id = p.id
|
||||
JOIN sessions s ON sp.session_id = s.id
|
||||
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
||||
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
|
||||
""",
|
||||
new { session.BatchId },
|
||||
transaction)).ToList();
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
transactionCommitted = true;
|
||||
|
||||
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
|
||||
await messenger.UpdateScheduleAsync(
|
||||
new PlatformScheduleMessage(
|
||||
command.Group,
|
||||
view,
|
||||
command.ScheduleMessage),
|
||||
ct);
|
||||
|
||||
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||
? "Вы удалены из листа ожидания."
|
||||
: promotedDisplayName is null
|
||||
? "Вы отписались от сессии."
|
||||
: $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}.";
|
||||
|
||||
await AnswerAsync(command.InteractionId, callbackText, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Ошибка при самостоятельной отмене записи на сессию {SessionId}", command.SessionId);
|
||||
if (!transactionCommitted)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
}
|
||||
|
||||
var errorText = transactionCommitted
|
||||
? "Запись снята, но не удалось обновить сообщение расписания."
|
||||
: "Произошла ошибка при отмене записи.";
|
||||
await AnswerAsync(command.InteractionId, errorText, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
|
||||
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Collections.Concurrent;
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
|
||||
public interface IScheduleMessageUpdateLock
|
||||
{
|
||||
ValueTask<IAsyncDisposable> AcquireAsync(PlatformMessageRef scheduleMessage, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class ScheduleMessageUpdateLock : IScheduleMessageUpdateLock
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> locks = new(StringComparer.Ordinal);
|
||||
|
||||
public async ValueTask<IAsyncDisposable> AcquireAsync(PlatformMessageRef scheduleMessage, CancellationToken ct)
|
||||
{
|
||||
var key = CreateKey(scheduleMessage);
|
||||
var semaphore = locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
|
||||
await semaphore.WaitAsync(ct);
|
||||
return new Releaser(semaphore);
|
||||
}
|
||||
|
||||
private static string CreateKey(PlatformMessageRef scheduleMessage) =>
|
||||
string.Join(
|
||||
'\u001F',
|
||||
scheduleMessage.Platform.ToString(),
|
||||
scheduleMessage.ExternalGroupId,
|
||||
scheduleMessage.ExternalThreadId ?? string.Empty,
|
||||
scheduleMessage.ExternalMessageId);
|
||||
|
||||
private sealed class Releaser(SemaphoreSlim semaphore) : IAsyncDisposable
|
||||
{
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
semaphore.Release();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,10 @@
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" Version="2.1.72" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
|
||||
<PackageReference Include="Npgsql" Version="10.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -2,11 +2,40 @@
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Dapper": {
|
||||
"type": "Direct",
|
||||
"requested": "[2.1.72, )",
|
||||
"resolved": "2.1.72",
|
||||
"contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw=="
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.5, )",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "9HOdqlDtPptVcmKAjsQ/Nr5Rxfq6FMYLdhvZh1lVmeKR738qeYecQD7+ldooXf+u2KzzR1kafSphWngIM3C6ug==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Npgsql": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.2, )",
|
||||
"resolved": "10.0.2",
|
||||
"contentHash": "q5RfBI+wywJSFUNDE1L4ZbHEHCFTblo8Uf6A6oe4feOUFYiUQXyAf9GBh5qEZpvJaHiEbpBPkQumjEhXCJxdrg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.0"
|
||||
}
|
||||
},
|
||||
"SecurityCodeScan.VS2019": {
|
||||
"type": "Direct",
|
||||
"requested": "[5.6.7, )",
|
||||
"resolved": "5.6.7",
|
||||
"contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ=="
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user