test: cover core bot and web scenarios
Deploy Telegram Bot / build-and-push (push) Successful in 4m18s
Deploy Telegram Bot / deploy (push) Successful in 20s

This commit is contained in:
2026-04-23 21:08:41 +03:00
parent 93e7c1ac66
commit bb8cbb7a40
17 changed files with 716 additions and 234 deletions
@@ -1,11 +1,8 @@
using System.Text.RegularExpressions;
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -16,37 +13,25 @@ public sealed class CreateSessionHandler(
{
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
{
var text = message.Text ?? "";
string? title = null;
string? link = null;
var scheduledTimes = new List<DateTimeOffset>();
var parseResult = NewSessionCommandParser.Parse(message.Text, DateTimeOffset.UtcNow);
foreach (var line in text.Split('\n'))
foreach (var timeInput in parseResult.PastTimeInputs)
{
var trimmed = line.Trim();
if (trimmed.StartsWith("Название:", StringComparison.OrdinalIgnoreCase))
title = trimmed["Название:".Length..].Trim();
else if (trimmed.StartsWith("Ссылка:", StringComparison.OrdinalIgnoreCase))
link = trimmed["Ссылка:".Length..].Trim();
else if (trimmed.StartsWith("Время:", StringComparison.OrdinalIgnoreCase))
{
var timeStr = trimmed["Время:".Length..].Trim();
if (MoscowTime.TryParseMoscow(timeStr, out var scheduledAt))
{
if (scheduledAt > DateTimeOffset.UtcNow)
scheduledTimes.Add(scheduledAt);
else
await botClient.SendMessage(message.Chat.Id, $"⚠️ Предупреждение: Дата {timeStr} находится в прошлом и будет пропущена.", cancellationToken: cancellationToken);
}
else
{
await botClient.SendMessage(message.Chat.Id, $"⚠️ Предупреждение: Некорректный формат времени '{timeStr}'. Пропущено.", cancellationToken: cancellationToken);
}
}
await botClient.SendMessage(
message.Chat.Id,
$"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.",
cancellationToken: cancellationToken);
}
if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(link) || scheduledTimes.Count == 0)
foreach (var timeInput in parseResult.InvalidTimeInputs)
{
await botClient.SendMessage(
message.Chat.Id,
$"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.",
cancellationToken: cancellationToken);
}
if (!parseResult.IsValid)
{
await botClient.SendMessage(
chatId: message.Chat.Id,
@@ -55,10 +40,12 @@ public sealed class CreateSessionHandler(
return;
}
var title = parseResult.Title!;
var link = parseResult.Link!;
var gmId = message.From!.Id;
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? "" : $" {message.From.LastName}");
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}");
var gmUsername = message.From.Username;
var chatId = message.Chat.Id;
var chatTitle = message.Chat.Title ?? "Private Chat";
@@ -67,20 +54,24 @@ public sealed class CreateSessionHandler(
try
{
// 1. Убеждаемся, что GM зарегистрирован
await connection.ExecuteAsync(
@"INSERT INTO players (telegram_id, display_name, telegram_username)
VALUES (@TgId, @Name, @Username)
ON CONFLICT (telegram_id) DO UPDATE SET display_name = EXCLUDED.display_name, telegram_username = EXCLUDED.telegram_username;",
"""
INSERT INTO players (telegram_id, display_name, telegram_username)
VALUES (@TgId, @Name, @Username)
ON CONFLICT (telegram_id) DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username;
""",
new { TgId = gmId, Name = gmName, Username = gmUsername },
transaction);
// 2. Убеждаемся, что Группа зарегистрирована
var groupId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id)
VALUES (@ChatId, @ChatName, @GmId)
ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name
RETURNING id;",
"""
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id)
VALUES (@ChatId, @ChatName, @GmId)
ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name
RETURNING id;
""",
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
transaction);
@@ -94,29 +85,36 @@ public sealed class CreateSessionHandler(
messageThreadId = topic.MessageThreadId;
}
// 3. Создаем сессии в цикле с общим batch_id
var batchId = Guid.NewGuid();
var sessions = new List<SessionBatchDto>();
foreach (var dt in scheduledTimes.OrderBy(d => d))
foreach (var scheduledAt in parseResult.ScheduledTimes.OrderBy(value => value))
{
var sessionId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, 'Planned', @ThreadId)
RETURNING id;",
new { BatchId = batchId, GroupId = groupId, Title = title, Link = link, ScheduledAt = dt, ThreadId = messageThreadId },
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, 'Planned', @ThreadId)
RETURNING id;
""",
new
{
BatchId = batchId,
GroupId = groupId,
Title = title,
Link = link,
ScheduledAt = scheduledAt,
ThreadId = messageThreadId
},
transaction);
sessions.Add(new SessionBatchDto(sessionId, dt.UtcDateTime, "Planned"));
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, "Planned"));
}
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
// 4. Отправляем сообщение в чат
var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty<ParticipantBatchDto>());
var batchMessage = await botClient.SendMessage(
chatId: chatId,
messageThreadId: messageThreadId,
@@ -125,12 +123,10 @@ public sealed class CreateSessionHandler(
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
// 4b. Сохраняем message_id батч-сообщения для дальнейшего редактирования
await connection.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
new { MsgId = batchMessage.MessageId, BatchId = batchId });
// 5. Удаляем исходное сообщение с командой /newsession, чтобы не спамить
try
{
await botClient.DeleteMessage(
@@ -0,0 +1,69 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
internal sealed record NewSessionParseResult(
string? Title,
string? Link,
IReadOnlyList<DateTimeOffset> ScheduledTimes,
IReadOnlyList<string> PastTimeInputs,
IReadOnlyList<string> InvalidTimeInputs)
{
public bool IsValid =>
!string.IsNullOrWhiteSpace(Title) &&
!string.IsNullOrWhiteSpace(Link) &&
ScheduledTimes.Count > 0;
}
internal static class NewSessionCommandParser
{
private const string TitlePrefix = "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435:";
private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:";
private const string LinkPrefix = "\u0421\u0441\u044b\u043b\u043a\u0430:";
public static NewSessionParseResult Parse(string? text, DateTimeOffset nowUtc)
{
string? title = null;
string? link = null;
var scheduledTimes = new List<DateTimeOffset>();
var pastTimeInputs = new List<string>();
var invalidTimeInputs = new List<string>();
foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries))
{
if (line.StartsWith(TitlePrefix, StringComparison.OrdinalIgnoreCase))
{
title = line[TitlePrefix.Length..].Trim();
continue;
}
if (line.StartsWith(LinkPrefix, StringComparison.OrdinalIgnoreCase))
{
link = line[LinkPrefix.Length..].Trim();
continue;
}
if (!line.StartsWith(TimePrefix, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var timeInput = line[TimePrefix.Length..].Trim();
if (!MoscowTime.TryParseMoscow(timeInput, out var scheduledAt))
{
invalidTimeInputs.Add(timeInput);
continue;
}
if (scheduledAt <= nowUtc)
{
pastTimeInputs.Add(timeInput);
continue;
}
scheduledTimes.Add(scheduledAt);
}
return new NewSessionParseResult(title, link, scheduledTimes, pastTimeInputs, invalidTimeInputs);
}
}