2819786f91
- Extract IWizardDraftRepository interface for testability (NSubstitute cannot mock sealed classes; the codebase uses fake-style doubles instead). - Add step-transition, pool-slot, validation, cancel/back, and render-shape tests using FakeWizardDraftRepository and FakeWizardMessenger. - Fix wizard payload persistence bug: HandleCallbackAsync and HandleTextAsync now call SavePayload after ApplyChoice/ApplyText mutations, so subsequent LoadPayload calls see the user's progress. Previously, local WizardPayload mutations were discarded and the wizard reset on every step. - CommitCurrentPoolSlot now auto-creates a slot via EnsureCurrentPoolSlot when one is missing, so the PoolSlotCapacity → waitlist click is recoverable even if the user lands on the step without a slot.
460 lines
19 KiB
C#
460 lines
19 KiB
C#
// ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment
|
||
using GmRelay.Shared.Domain;
|
||
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||
using GmRelay.Shared.Rendering;
|
||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||
using GmRelay.Bot.Features.Sessions.ListSessions;
|
||
using BotCreateSessionHandler = GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler;
|
||
using BotRescheduleTimeInputHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
|
||
using BotRescheduleVoteHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler;
|
||
using GmRelay.Bot.Features.Sessions.ExportCalendar;
|
||
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||
using Telegram.Bot;
|
||
using Telegram.Bot.Types;
|
||
using Telegram.Bot.Types.Enums;
|
||
using Telegram.Bot.Types.ReplyMarkups;
|
||
|
||
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||
|
||
/// <summary>
|
||
/// Routes incoming Telegram updates to the appropriate feature handler.
|
||
/// No reflection — all routing is explicit (AOT-safe).
|
||
/// </summary>
|
||
public sealed class UpdateRouter(
|
||
HandleRsvpHandler rsvpHandler,
|
||
BotCreateSessionHandler createSessionHandler,
|
||
JoinSessionHandler joinSessionHandler,
|
||
LeaveSessionHandler leaveSessionHandler,
|
||
PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler,
|
||
CancelSessionHandler cancelSessionHandler,
|
||
DeleteSessionHandler deleteSessionHandler,
|
||
ListSessionsHandler listSessionsHandler,
|
||
ExportCalendarHandler exportCalendarHandler,
|
||
InitiateRescheduleHandler initiateRescheduleHandler,
|
||
BotRescheduleTimeInputHandler rescheduleTimeInputHandler,
|
||
BotRescheduleVoteHandler rescheduleVoteHandler,
|
||
GameCreationWizard wizard,
|
||
IWizardDraftRepository drafts,
|
||
ITelegramBotClient bot,
|
||
IConfiguration configuration,
|
||
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
|
||
{
|
||
public async Task RouteAsync(Update update, CancellationToken ct)
|
||
{
|
||
// 1) Wizard delegation. If the GM has an active (non-expired) draft for this
|
||
// (chat, thread, owner), every update routes to the wizard. The wizard is
|
||
// responsible for both text input and callback handling.
|
||
if (TryGetWizardContext(update, out var chatId, out var threadId, out var ownerId))
|
||
{
|
||
var draft = await drafts.GetActiveAsync(chatId, threadId, ownerId, ct);
|
||
if (draft is not null)
|
||
{
|
||
// Resume / Reset / Cancel menu callbacks live in the router because
|
||
// they cross draft boundaries (reset deletes + recreates a fresh
|
||
// draft, which the wizard instance doesn't know how to do).
|
||
if (await TryHandleDraftControlCallbackAsync(update, draft, ct))
|
||
{
|
||
return;
|
||
}
|
||
|
||
await wizard.HandleUpdateAsync(update, draft, ct);
|
||
|
||
// The "✅ Создать" / "✅ Создать пул" button — the wizard only
|
||
// acknowledges the callback; the actual session creation lives in
|
||
// CreateSessionHandler.
|
||
if (update.CallbackQuery?.Data is { } data &&
|
||
data == WizardCallbackData.Create())
|
||
{
|
||
await createSessionHandler.SubmitDraftAsync(draft, ct);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
switch (update)
|
||
{
|
||
case { CallbackQuery: { } query }:
|
||
await HandleCallbackQueryAsync(query, ct);
|
||
break;
|
||
|
||
case { Message: { } message }:
|
||
var commandText = GetCommandText(message);
|
||
if (commandText.StartsWith("/", StringComparison.Ordinal))
|
||
{
|
||
await HandleCommandAsync(message, commandText, ct);
|
||
break;
|
||
}
|
||
|
||
if (message.Text is not null)
|
||
{
|
||
await rescheduleTimeInputHandler.TryHandleAsync(message, ct);
|
||
}
|
||
|
||
break;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Handles router-level draft-control callbacks ("resume", "reset"). Returns true
|
||
/// if the update was consumed and the wizard should be skipped. The wizard still
|
||
/// owns "cancel" and "create".
|
||
/// </summary>
|
||
private async Task<bool> TryHandleDraftControlCallbackAsync(
|
||
Update update, WizardDraft draft, CancellationToken ct)
|
||
{
|
||
if (update.CallbackQuery is not { Data: { } data, Message: { } cbMessage, From: { } cbFrom })
|
||
return false;
|
||
|
||
switch (data)
|
||
{
|
||
case WizardControlCallbacks.Resume:
|
||
// Re-render the current step of the existing draft. We answer the
|
||
// callback here because the wizard will not be called.
|
||
var (text, kb) = WizardStep.Render(draft, LoadPayload(draft));
|
||
await bot.EditMessageText(
|
||
chatId: cbMessage.Chat.Id,
|
||
messageId: cbMessage.MessageId,
|
||
text: text,
|
||
replyMarkup: kb,
|
||
cancellationToken: ct);
|
||
await bot.AnswerCallbackQuery(update.CallbackQuery.Id, cancellationToken: ct);
|
||
return true;
|
||
|
||
case WizardControlCallbacks.Reset:
|
||
// Delete the existing draft and start a fresh one. The wizard is
|
||
// bypassed entirely because the active draft is now gone.
|
||
await drafts.DeleteAsync(draft.Id, ct);
|
||
await bot.AnswerCallbackQuery(update.CallbackQuery.Id, cancellationToken: ct);
|
||
var newDraft = await createSessionHandler.StartWizardAsync(
|
||
SyntheticStartMessage(cbMessage.Chat.Id, cbMessage.MessageThreadId, cbFrom.Id), ct);
|
||
if (newDraft is null)
|
||
{
|
||
// Race: another wizard just started for the same owner. The
|
||
// user can simply re-run /newsession. We don't loop.
|
||
await bot.SendMessage(
|
||
chatId: cbMessage.Chat.Id,
|
||
text: "Не удалось начать заново — попробуйте ещё раз через /newsession.",
|
||
cancellationToken: ct);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Build a synthetic <see cref="Message"/> carrying just the fields
|
||
/// <see cref="CreateSessionHandler.StartWizardAsync"/> reads (chat, thread, from).
|
||
/// </summary>
|
||
private static Message SyntheticStartMessage(long chatId, int? messageThreadId, long fromId) => new()
|
||
{
|
||
Chat = new Chat { Id = chatId },
|
||
MessageThreadId = messageThreadId,
|
||
From = new User { Id = fromId },
|
||
};
|
||
|
||
private static WizardPayload LoadPayload(WizardDraft draft) =>
|
||
GameCreationWizard.LoadPayload(draft);
|
||
|
||
internal static string GetCommandText(Message message)
|
||
=> (message.Text ?? message.Caption ?? string.Empty).TrimStart();
|
||
|
||
/// <summary>
|
||
/// Extracts the (chat, thread, owner) triple from an update for wizard lookups.
|
||
/// Returns false for updates that carry no usable origin (e.g. inline queries).
|
||
/// </summary>
|
||
private static bool TryGetWizardContext(Update update, out long chatId, out int? messageThreadId, out long ownerId)
|
||
{
|
||
chatId = 0;
|
||
messageThreadId = null;
|
||
ownerId = 0;
|
||
|
||
switch (update)
|
||
{
|
||
case { Message: { From: not null, Chat: { } chat } msg }:
|
||
chatId = chat.Id;
|
||
messageThreadId = msg.MessageThreadId;
|
||
ownerId = msg.From!.Id;
|
||
return true;
|
||
|
||
case { CallbackQuery: { From: not null, Message: { Chat: { } cbmChat } } cb }:
|
||
chatId = cbmChat.Id;
|
||
messageThreadId = cb.Message?.MessageThreadId;
|
||
ownerId = cb.From!.Id;
|
||
return true;
|
||
|
||
case { CallbackQuery: { From: not null } cb2 }:
|
||
// Callback arrived without a message (e.g. from a Mini App). No chat
|
||
// context → wizard cannot run on this update.
|
||
ownerId = cb2.From!.Id;
|
||
return false;
|
||
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private async Task HandleCallbackQueryAsync(CallbackQuery query, CancellationToken ct)
|
||
{
|
||
if (query.Data is not { } data || query.Message is not { } message)
|
||
return;
|
||
|
||
var parts = data.Split(':', 3);
|
||
var action = parts[0];
|
||
var user = TelegramPlatformIds.User(
|
||
query.From.Id,
|
||
query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"),
|
||
query.From.Username);
|
||
var group = TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title);
|
||
var scheduleMessage = TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, message.MessageId);
|
||
|
||
if (action == "join_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var joinSessionId))
|
||
{
|
||
var command = new JoinSessionCommand(
|
||
SessionId: joinSessionId,
|
||
User: user,
|
||
InteractionId: query.Id,
|
||
Group: group,
|
||
ScheduleMessage: scheduleMessage);
|
||
|
||
await joinSessionHandler.HandleAsync(command, ct);
|
||
return;
|
||
}
|
||
|
||
if (action == "leave_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var leaveSessionId))
|
||
{
|
||
var command = new LeaveSessionCommand(
|
||
SessionId: leaveSessionId,
|
||
User: user,
|
||
InteractionId: query.Id,
|
||
Group: group,
|
||
ScheduleMessage: scheduleMessage);
|
||
|
||
await leaveSessionHandler.HandleAsync(command, ct);
|
||
return;
|
||
}
|
||
|
||
if (action == "cancel_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var cancelSessionId))
|
||
{
|
||
var command = new CancelSessionCommand(
|
||
SessionId: cancelSessionId,
|
||
TelegramUserId: query.From.Id,
|
||
CallbackQueryId: query.Id,
|
||
ChatId: message.Chat.Id,
|
||
MessageThreadId: message.MessageThreadId,
|
||
MessageId: message.MessageId);
|
||
|
||
await cancelSessionHandler.HandleAsync(command, ct);
|
||
return;
|
||
}
|
||
|
||
if (action == "promote_waitlist" && parts.Length >= 2 && Guid.TryParse(parts[1], out var promoteSessionId))
|
||
{
|
||
var command = new PromoteWaitlistedPlayerCommand(
|
||
SessionId: promoteSessionId,
|
||
TelegramUserId: query.From.Id,
|
||
CallbackQueryId: query.Id,
|
||
ChatId: message.Chat.Id,
|
||
MessageId: message.MessageId);
|
||
|
||
await promoteWaitlistedPlayerHandler.HandleAsync(command, ct);
|
||
return;
|
||
}
|
||
|
||
if (action == "delete_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var deleteSessionId))
|
||
{
|
||
var command = new DeleteSessionCommand(
|
||
SessionId: deleteSessionId,
|
||
TelegramUserId: query.From.Id,
|
||
CallbackQueryId: query.Id,
|
||
ChatId: message.Chat.Id,
|
||
MessageId: message.MessageId);
|
||
|
||
await deleteSessionHandler.HandleAsync(command, ct);
|
||
return;
|
||
}
|
||
|
||
if (action == "reschedule_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var rescheduleSessionId))
|
||
{
|
||
var command = new InitiateRescheduleCommand(
|
||
SessionId: rescheduleSessionId,
|
||
TelegramUserId: query.From.Id,
|
||
CallbackQueryId: query.Id,
|
||
ChatId: message.Chat.Id,
|
||
MessageThreadId: message.MessageThreadId,
|
||
MessageId: message.MessageId);
|
||
|
||
await initiateRescheduleHandler.HandleAsync(command, ct);
|
||
return;
|
||
}
|
||
|
||
if (action == "reschedule_vote" && parts.Length >= 2 && Guid.TryParse(parts[1], out var optionId))
|
||
{
|
||
var command = new HandleRescheduleVoteCommand(
|
||
OptionId: optionId,
|
||
TelegramUserId: query.From.Id,
|
||
CallbackQueryId: query.Id,
|
||
ChatId: message.Chat.Id,
|
||
MessageId: message.MessageId);
|
||
|
||
await rescheduleVoteHandler.HandleAsync(command, ct);
|
||
return;
|
||
}
|
||
|
||
if (action == "rsvp")
|
||
{
|
||
if (parts.Length < 3 || !Guid.TryParse(parts[2], out var sessionId))
|
||
return;
|
||
|
||
var status = parts[1] switch
|
||
{
|
||
"confirm" => RsvpStatus.Confirmed,
|
||
"decline" => RsvpStatus.Declined,
|
||
_ => (string?)null
|
||
};
|
||
|
||
if (status is null)
|
||
return;
|
||
|
||
var command = new HandleRsvpCommand(
|
||
SessionId: sessionId,
|
||
User: user,
|
||
Status: status,
|
||
InteractionId: query.Id,
|
||
Group: group,
|
||
ConfirmationMessage: scheduleMessage);
|
||
|
||
await rsvpHandler.HandleAsync(command, ct);
|
||
}
|
||
}
|
||
|
||
private async Task HandleCommandAsync(Message message, string text, CancellationToken ct)
|
||
{
|
||
// Извлекаем команду: берём первую строку, первое слово, убираем @BotUsername
|
||
var firstLine = text.Split('\n')[0].Trim();
|
||
var command = firstLine.Split(' ')[0].Split('@')[0].ToLowerInvariant();
|
||
|
||
switch (command)
|
||
{
|
||
case "/start":
|
||
await SendStartMessageAsync(message, ct);
|
||
break;
|
||
|
||
case "/newsession":
|
||
await HandleNewSessionCommandAsync(message, ct);
|
||
break;
|
||
|
||
case "/listsessions":
|
||
await listSessionsHandler.HandleAsync(message, ct);
|
||
break;
|
||
|
||
case "/exportcalendar":
|
||
await exportCalendarHandler.HandleAsync(message, ct);
|
||
break;
|
||
|
||
case "/help":
|
||
await bot.SendMessage(
|
||
chatId: message.Chat.Id,
|
||
text: """
|
||
GM-Relay — бот для управления игровыми сессиями.
|
||
|
||
/newsession
|
||
Название: My Game
|
||
Время: 15.05.2026 19:30
|
||
Мест: 4
|
||
Ссылка: https://link
|
||
Картинка: https://cover
|
||
|
||
Для регулярного расписания можно указать одну дату:
|
||
Игр: 4
|
||
Интервал: 7
|
||
|
||
/listsessions — список предстоящих сессий
|
||
Для owner/co-GM /listsessions показывает кнопки отмены, переноса, удаления и повышения из листа ожидания.
|
||
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
|
||
/help — эта справка
|
||
""",
|
||
cancellationToken: ct);
|
||
break;
|
||
|
||
// TODO: /listsessions — will be implemented as features
|
||
default:
|
||
logger.LogDebug("Unknown command: {Command}", command);
|
||
break;
|
||
}
|
||
}
|
||
|
||
private async Task HandleNewSessionCommandAsync(Message message, CancellationToken ct)
|
||
{
|
||
// Try to start a fresh wizard. StartWizardAsync returns null when a
|
||
// non-expired draft already exists for this (chat, thread, owner).
|
||
var draft = await createSessionHandler.StartWizardAsync(message, ct);
|
||
if (draft is not null)
|
||
{
|
||
// New draft was created and its first step has been rendered.
|
||
return;
|
||
}
|
||
|
||
// Existing draft. Look it up so we can describe the current step and offer
|
||
// a Continue / Start over / Cancel menu.
|
||
var existing = await createSessionHandler.TryResumeAsync(message, ct);
|
||
if (existing is null)
|
||
{
|
||
// Race: the draft expired between the two calls (or the user lacks
|
||
// ownership metadata). Fall back to silently starting a new wizard.
|
||
await createSessionHandler.StartWizardAsync(message, ct);
|
||
return;
|
||
}
|
||
|
||
await bot.SendMessage(
|
||
chatId: message.Chat.Id,
|
||
text: "У вас уже есть незавершённый мастер. Продолжить?",
|
||
replyMarkup: ContinueResetCancelKeyboard(),
|
||
cancellationToken: ct);
|
||
}
|
||
|
||
private InlineKeyboardMarkup ContinueResetCancelKeyboard() => new(new[]
|
||
{
|
||
// "Продолжить" re-renders the existing draft's current step (router-level).
|
||
// "Начать заново" deletes the existing draft and creates a fresh one (router-level).
|
||
// "Отмена" delegates to the wizard's normal cancel handler.
|
||
new[] { InlineKeyboardButton.WithCallbackData("➡️ Продолжить", WizardControlCallbacks.Resume) },
|
||
new[] { InlineKeyboardButton.WithCallbackData("🔁 Начать заново", WizardControlCallbacks.Reset) },
|
||
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
|
||
});
|
||
|
||
private async Task SendStartMessageAsync(Message message, CancellationToken ct)
|
||
{
|
||
var miniAppUrl = configuration["Telegram:MiniAppUrl"];
|
||
if (string.IsNullOrWhiteSpace(miniAppUrl))
|
||
{
|
||
await bot.SendMessage(
|
||
chatId: message.Chat.Id,
|
||
text: "GM-Relay Bot ready. Use /help for commands.",
|
||
cancellationToken: ct);
|
||
return;
|
||
}
|
||
|
||
await bot.SendMessage(
|
||
chatId: message.Chat.Id,
|
||
text: "GM-Relay Bot ready. Откройте dashboard внутри Telegram или используйте /help для команд.",
|
||
replyMarkup: new InlineKeyboardMarkup(
|
||
InlineKeyboardButton.WithWebApp("Открыть dashboard", new WebAppInfo(miniAppUrl))),
|
||
cancellationToken: ct);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Router-level callback data for the Continue / Start over / Cancel menu shown
|
||
/// when /newsession detects an existing wizard draft. Distinct from
|
||
/// <see cref="WizardCallbackData"/> which is parsed and consumed by the wizard itself.
|
||
/// </summary>
|
||
internal static class WizardControlCallbacks
|
||
{
|
||
public const string Resume = "wizard:resume";
|
||
public const string Reset = "wizard:reset";
|
||
}
|