Initial commit: GM-Relay Telegram Bot
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
|
||||
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
/// <summary>
|
||||
/// Long polling loop for Telegram Bot API.
|
||||
/// Stateless — all state is in PostgreSQL. Safe to restart at any time.
|
||||
/// </summary>
|
||||
public sealed class TelegramBotService(
|
||||
ITelegramBotClient bot,
|
||||
UpdateRouter router,
|
||||
ILogger<TelegramBotService> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
logger.LogInformation("Telegram bot polling started");
|
||||
|
||||
// Skip any pending updates from before this startup
|
||||
try
|
||||
{
|
||||
var pending = await bot.GetUpdates(offset: -1, limit: 1, cancellationToken: stoppingToken);
|
||||
if (pending.Length > 0)
|
||||
{
|
||||
logger.LogInformation("Skipped {Count} pending update(s)", pending[^1].Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to clear pending updates, continuing anyway");
|
||||
}
|
||||
|
||||
var offset = 0;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var updates = await bot.GetUpdates(
|
||||
offset: offset,
|
||||
timeout: 30,
|
||||
allowedUpdates: [UpdateType.Message, UpdateType.CallbackQuery],
|
||||
cancellationToken: stoppingToken);
|
||||
|
||||
foreach (var update in updates)
|
||||
{
|
||||
try
|
||||
{
|
||||
await router.RouteAsync(update, stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error handling update {UpdateId}", update.Id);
|
||||
}
|
||||
|
||||
offset = update.Id + 1;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Polling error, retrying in 5s");
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("Telegram bot polling stopped");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment
|
||||
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
using GmRelay.Bot.Features.Sessions.ListSessions;
|
||||
using GmRelay.Bot.Features.Sessions.ExportCalendar;
|
||||
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
|
||||
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,
|
||||
CreateSessionHandler createSessionHandler,
|
||||
JoinSessionHandler joinSessionHandler,
|
||||
CancelSessionHandler cancelSessionHandler,
|
||||
DeleteSessionHandler deleteSessionHandler,
|
||||
ListSessionsHandler listSessionsHandler,
|
||||
ExportCalendarHandler exportCalendarHandler,
|
||||
InitiateRescheduleHandler initiateRescheduleHandler,
|
||||
HandleRescheduleTimeInputHandler rescheduleTimeInputHandler,
|
||||
HandleRescheduleVoteHandler rescheduleVoteHandler,
|
||||
ITelegramBotClient bot,
|
||||
ILogger<UpdateRouter> logger)
|
||||
{
|
||||
public async Task RouteAsync(Update update, CancellationToken ct)
|
||||
{
|
||||
switch (update)
|
||||
{
|
||||
case { CallbackQuery: { } query }:
|
||||
await HandleCallbackQueryAsync(query, ct);
|
||||
break;
|
||||
|
||||
case { Message: { Text: { } text } message } when text.StartsWith('/'):
|
||||
await HandleCommandAsync(message, text, ct);
|
||||
break;
|
||||
|
||||
// Non-command text messages — check for reschedule time input
|
||||
case { Message: { Text: { } } message } when !message.Text!.StartsWith('/'):
|
||||
await rescheduleTimeInputHandler.TryHandleAsync(message, ct);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
|
||||
if (action == "join_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var joinSessionId))
|
||||
{
|
||||
var command = new JoinSessionCommand(
|
||||
SessionId: joinSessionId,
|
||||
TelegramUserId: query.From.Id,
|
||||
DisplayName: query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"),
|
||||
TelegramUsername: query.From.Username,
|
||||
CallbackQueryId: query.Id,
|
||||
ChatId: message.Chat.Id,
|
||||
MessageId: message.MessageId);
|
||||
|
||||
await joinSessionHandler.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,
|
||||
MessageId: message.MessageId);
|
||||
|
||||
await cancelSessionHandler.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,
|
||||
MessageId: message.MessageId);
|
||||
|
||||
await initiateRescheduleHandler.HandleAsync(command, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action == "reschedule_vote" && parts.Length >= 3 && Guid.TryParse(parts[2], out var proposalId))
|
||||
{
|
||||
var vote = parts[1]; // "yes" or "no"
|
||||
if (vote is not ("yes" or "no"))
|
||||
return;
|
||||
|
||||
var command = new HandleRescheduleVoteCommand(
|
||||
ProposalId: proposalId,
|
||||
Vote: vote,
|
||||
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" => Domain.RsvpStatus.Confirmed,
|
||||
"decline" => Domain.RsvpStatus.Declined,
|
||||
_ => (string?)null
|
||||
};
|
||||
|
||||
if (status is null)
|
||||
return;
|
||||
|
||||
var command = new HandleRsvpCommand(
|
||||
SessionId: sessionId,
|
||||
TelegramUserId: query.From.Id,
|
||||
Status: status,
|
||||
CallbackQueryId: query.Id,
|
||||
ChatId: message.Chat.Id,
|
||||
MessageId: message.MessageId);
|
||||
|
||||
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 bot.SendMessage(
|
||||
chatId: message.Chat.Id,
|
||||
text: "GM-Relay Bot ready. Use /help for commands.",
|
||||
cancellationToken: ct);
|
||||
break;
|
||||
|
||||
case "/newsession":
|
||||
await createSessionHandler.HandleAsync(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
|
||||
Ссылка: https://link
|
||||
|
||||
/listsessions — список предстоящих сессий
|
||||
/help — эта справка
|
||||
""",
|
||||
cancellationToken: ct);
|
||||
break;
|
||||
|
||||
// TODO: /listsessions — will be implemented as features
|
||||
default:
|
||||
logger.LogDebug("Unknown command: {Command}", command);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user