feat: add campaign templates and recurring schedules
Deploy Telegram Bot / build-and-push (push) Successful in 3m49s
Deploy Telegram Bot / deploy (push) Successful in 10s

This commit is contained in:
2026-04-28 10:01:18 +03:00
parent a1ec688ec8
commit 0218890a7a
17 changed files with 1075 additions and 14 deletions
@@ -42,11 +42,19 @@ public sealed class CreateSessionHandler(
cancellationToken: cancellationToken);
}
foreach (var recurringInput in parseResult.InvalidRecurringInputs)
{
await botClient.SendMessage(
message.Chat.Id,
$"⚠️ Предупреждение: некорректный повтор расписания '{recurringInput}'. Укажите число игр 1-52 и шаг 1-365 дней.",
cancellationToken: cancellationToken);
}
if (!parseResult.IsValid)
{
await botClient.SendMessage(
chatId: message.Chat.Id,
text: "❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:\n\n/newsession\nНазвание: My Game\nВремя: 15.05.2026 19:30\nВремя: 22.05.2026 19:30\nМест: 4\nСсылка: https://link",
text: "❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:\n\n/newsession\nНазвание: My Game\nВремя: 15.05.2026 19:30\nВремя: 22.05.2026 19:30\nМест: 4\nСсылка: https://link\n\nДля повтора можно указать одну дату и строки:\nИгр: 4\nИнтервал: 7",
cancellationToken: cancellationToken);
return;
}
@@ -9,17 +9,21 @@ internal sealed record NewSessionParseResult(
IReadOnlyList<DateTimeOffset> ScheduledTimes,
IReadOnlyList<string> PastTimeInputs,
IReadOnlyList<string> InvalidTimeInputs,
IReadOnlyList<string> InvalidSeatLimitInputs)
IReadOnlyList<string> InvalidSeatLimitInputs,
IReadOnlyList<string> InvalidRecurringInputs)
{
public bool IsValid =>
!string.IsNullOrWhiteSpace(Title) &&
!string.IsNullOrWhiteSpace(Link) &&
ScheduledTimes.Count > 0 &&
InvalidSeatLimitInputs.Count == 0;
InvalidSeatLimitInputs.Count == 0 &&
InvalidRecurringInputs.Count == 0;
}
internal static class NewSessionCommandParser
{
private const int MaxRecurringSessionCount = 52;
private const int MaxRecurringIntervalDays = 365;
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:";
@@ -29,16 +33,30 @@ internal static class NewSessionCommandParser
"\u041b\u0438\u043c\u0438\u0442:",
"\u041c\u0430\u043a\u0441\u0438\u043c\u0443\u043c:"
];
private static readonly string[] RecurringCountPrefixes =
[
"\u0418\u0433\u0440:",
"\u0421\u0435\u0441\u0441\u0438\u0439:",
"\u041f\u043e\u0432\u0442\u043e\u0440\u043e\u0432:"
];
private static readonly string[] RecurringIntervalPrefixes =
[
"\u0428\u0430\u0433:",
"\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b:"
];
public static NewSessionParseResult Parse(string? text, DateTimeOffset nowUtc)
{
string? title = null;
string? link = null;
int? maxPlayers = null;
int? recurringCount = null;
var recurringIntervalDays = 7;
var scheduledTimes = new List<DateTimeOffset>();
var pastTimeInputs = new List<string>();
var invalidTimeInputs = new List<string>();
var invalidSeatLimitInputs = new List<string>();
var invalidRecurringInputs = new List<string>();
foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries))
{
@@ -71,6 +89,42 @@ internal static class NewSessionCommandParser
continue;
}
var recurringCountPrefix = RecurringCountPrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (recurringCountPrefix is not null)
{
var recurringInput = line[recurringCountPrefix.Length..].Trim();
if (int.TryParse(recurringInput, out var parsedCount) &&
parsedCount is >= 1 and <= MaxRecurringSessionCount)
{
recurringCount = parsedCount;
}
else
{
invalidRecurringInputs.Add(recurringInput);
}
continue;
}
var recurringIntervalPrefix = RecurringIntervalPrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (recurringIntervalPrefix is not null)
{
var recurringInput = line[recurringIntervalPrefix.Length..].Trim();
if (int.TryParse(recurringInput, out var parsedInterval) &&
parsedInterval is >= 1 and <= MaxRecurringIntervalDays)
{
recurringIntervalDays = parsedInterval;
}
else
{
invalidRecurringInputs.Add(recurringInput);
}
continue;
}
if (!line.StartsWith(TimePrefix, StringComparison.OrdinalIgnoreCase))
{
continue;
@@ -92,6 +146,14 @@ internal static class NewSessionCommandParser
scheduledTimes.Add(scheduledAt);
}
if (recurringCount.HasValue && scheduledTimes.Count == 1)
{
var firstScheduledTime = scheduledTimes[0];
scheduledTimes = Enumerable.Range(0, recurringCount.Value)
.Select(index => firstScheduledTime.AddDays(recurringIntervalDays * index))
.ToList();
}
return new NewSessionParseResult(
title,
link,
@@ -99,6 +161,7 @@ internal static class NewSessionCommandParser
scheduledTimes,
pastTimeInputs,
invalidTimeInputs,
invalidSeatLimitInputs);
invalidSeatLimitInputs,
invalidRecurringInputs);
}
}
@@ -218,6 +218,10 @@ public sealed class UpdateRouter(
Мест: 4
Ссылка: https://link
Для регулярного расписания можно указать одну дату:
Игр: 4
Интервал: 7
/listsessions список предстоящих сессий
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
Owner и co-GM могут переносить сессии кнопкой «Перенести»: бот попросит 2-3 варианта времени и дедлайн голосования.
@@ -0,0 +1,17 @@
CREATE TABLE campaign_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
title VARCHAR(500) NOT NULL,
join_link TEXT NOT NULL,
session_count INTEGER NOT NULL CHECK (session_count BETWEEN 1 AND 52),
interval_days INTEGER NOT NULL CHECK (interval_days BETWEEN 1 AND 365),
max_players INTEGER CHECK (max_players IS NULL OR max_players > 0),
notification_mode VARCHAR(32) NOT NULL DEFAULT 'GroupAndDirect'
CHECK (notification_mode IN ('GroupAndDirect', 'GroupOnly')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (group_id, name)
);
CREATE INDEX ix_campaign_templates_group ON campaign_templates (group_id, created_at DESC);