8f0f2ef7e7
Moves the game-creation wizard state machine, view builder, and platform-neutral contracts (callback data, step names, storage exception, club option, step limits) from GmRelay.Bot to GmRelay.Shared. Telegram continues to work through a new TelegramWizardMessenger implementing IWizardMessenger and a WizardInteractionMapper that converts Update → WizardInteraction. Wires the new platform column on wizard_drafts (V032 migration) and switches chat/owner/thread/message ids to TEXT so the same table can hold Discord snowflakes later. - GameCreationWizard: now in Shared, takes IWizardMessenger + IWizardDraftRepository, dispatches on WizardInteraction. - New IWizardMessenger contract with Edit/Send/Answer/GetOwnerClubs (returns string ids so Telegram longs and Discord snowflakes both fit). - New WizardStepViewBuilder in Shared returns (text, IReadOnlyList<WizardAction>); TelegramWizardMessenger renders actions into InlineKeyboardMarkup via a new Bot-side ToInlineKeyboard helper. - New WizardInteractionMapper in Bot (5-case test) converts Telegram Update to WizardInteraction. - WizardDraft gains a Platform column; ChatId/MessageThreadId/OwnerId/ DraftMessageId switched to string. V032 migrates existing rows and rebuilds the owner lookup index on (platform, owner_id). - All existing wizard / create-session tests updated to the new contract (HandleInteractionAsync + WizardInteraction). Wizard callback-data format preserved. - dotnet build clean, dotnet format --verify-no-changes clean, all 101 wizard tests pass.
137 lines
5.0 KiB
C#
137 lines
5.0 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Dapper;
|
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
|
using Npgsql;
|
|
using Telegram.Bot;
|
|
using Telegram.Bot.Types.ReplyMarkups;
|
|
|
|
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|
|
|
/// <summary>
|
|
/// Telegram-side implementation of <see cref="IWizardMessenger"/>.
|
|
/// Translates the platform-neutral wizard contracts into the
|
|
/// <c>Telegram.Bot</c> SDK calls. All Telegram-specific behaviour
|
|
/// (message editing, callback ack, group lookup) lives behind the
|
|
/// interface so the wizard core stays in <c>GmRelay.Shared</c>.
|
|
/// </summary>
|
|
public sealed class TelegramWizardMessenger(
|
|
ITelegramBotClient bot,
|
|
NpgsqlDataSource dataSource) : IWizardMessenger
|
|
{
|
|
public async Task<string> EditDraftMessageAsync(
|
|
WizardDraft draft,
|
|
string text,
|
|
IReadOnlyList<WizardAction> keyboard,
|
|
CancellationToken ct)
|
|
{
|
|
if (!TryParseChatId(draft.ChatId, out var chatId))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Wizard draft {draft.Id} has un-parseable chat id '{draft.ChatId}'.");
|
|
}
|
|
if (!TryParseMessageId(draft.DraftMessageId, out var messageId))
|
|
{
|
|
// No draft message recorded yet — fall back to sending a new one.
|
|
return await SendDraftMessageAsync(draft, text, keyboard, ct);
|
|
}
|
|
var msg = await bot.EditMessageText(
|
|
chatId: chatId,
|
|
messageId: messageId,
|
|
text: text,
|
|
replyMarkup: WizardStep.ToInlineKeyboard(keyboard),
|
|
cancellationToken: ct);
|
|
return msg.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
public async Task<string> SendDraftMessageAsync(
|
|
WizardDraft draft,
|
|
string text,
|
|
IReadOnlyList<WizardAction> keyboard,
|
|
CancellationToken ct)
|
|
{
|
|
if (!TryParseChatId(draft.ChatId, out var chatId))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Wizard draft {draft.Id} has un-parseable chat id '{draft.ChatId}'.");
|
|
}
|
|
int? threadId = TryParseThreadId(draft.MessageThreadId, out var parsedThread)
|
|
? parsedThread
|
|
: null;
|
|
|
|
var msg = await bot.SendMessage(
|
|
chatId: chatId,
|
|
text: text,
|
|
messageThreadId: threadId,
|
|
replyMarkup: WizardStep.ToInlineKeyboard(keyboard),
|
|
cancellationToken: ct);
|
|
return msg.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
public Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct)
|
|
{
|
|
return bot.AnswerCallbackQuery(interactionId, text: text, cancellationToken: ct);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<WizardClubOption>> GetOwnerClubsAsync(string ownerId, CancellationToken ct)
|
|
{
|
|
// Adjusted from the plan: this codebase models "clubs" as game_groups
|
|
// (V001 created game_groups; V026 added public_slug; no `clubs` table exists,
|
|
// and game_groups has no `club_id` FK). The picker therefore returns the
|
|
// game_groups the owner manages as a GM (via group_managers), matching
|
|
// the WizardClubOption contract (UUID id, name) used downstream.
|
|
const string sql = """
|
|
SELECT g.id AS ClubId,
|
|
g.name AS Name
|
|
FROM game_groups g
|
|
JOIN group_managers gm ON gm.group_id = g.id
|
|
JOIN players p ON p.id = gm.player_id
|
|
WHERE p.platform = @Platform
|
|
AND p.external_user_id = @ExternalId
|
|
GROUP BY g.id, g.name
|
|
ORDER BY g.name
|
|
""";
|
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
|
var rows = await connection.QueryAsync<WizardClubOption>(
|
|
new CommandDefinition(
|
|
sql,
|
|
new { Platform = "Telegram", ExternalId = ownerId },
|
|
cancellationToken: ct));
|
|
return rows.AsList();
|
|
}
|
|
|
|
private static bool TryParseChatId(string raw, out long chatId)
|
|
{
|
|
if (long.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out chatId))
|
|
{
|
|
return true;
|
|
}
|
|
chatId = 0;
|
|
return false;
|
|
}
|
|
|
|
private static bool TryParseMessageId(string? raw, out int messageId)
|
|
{
|
|
if (raw is not null &&
|
|
int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out messageId))
|
|
{
|
|
return true;
|
|
}
|
|
messageId = 0;
|
|
return false;
|
|
}
|
|
|
|
private static bool TryParseThreadId(string? raw, out int threadId)
|
|
{
|
|
if (raw is not null &&
|
|
int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out threadId))
|
|
{
|
|
return true;
|
|
}
|
|
threadId = 0;
|
|
return false;
|
|
}
|
|
}
|