1 Commits

Author SHA1 Message Date
Toutsu 0218890a7a 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
2026-04-28 10:01:18 +03:00
17 changed files with 1075 additions and 14 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 1.7.0 VERSION: 1.8.0
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>1.7.0</Version> <Version>1.8.0</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+20 -2
View File
@@ -4,7 +4,7 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v1.7.0`. **Текущая версия:** `v1.8.0`.
--- ---
@@ -12,6 +12,7 @@
### 🤖 Telegram Бот ### 🤖 Telegram Бот
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед). - **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед).
- **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch.
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки. - **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему. - **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему.
- **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр. - **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр.
@@ -25,6 +26,7 @@
- **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация). - **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация).
- **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов. - **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов.
- **🤝 Co-GM и делегирование**: Owner группы назначает помощников по Telegram ID, а co-GM получает доступ к управлению расписанием в Telegram и Web Dashboard. - **🤝 Co-GM и делегирование**: Owner группы назначает помощников по Telegram ID, а co-GM получает доступ к управлению расписанием в Telegram и Web Dashboard.
- **📋 Шаблоны кампаний**: Owner и co-GM сохраняют типовые параметры кампании и создают новый повторяющийся batch из шаблона за один шаг.
- **🧩 Bulk-операции для Batch Sessions**: ГМ может обновить общий title/link, перенести всю пачку на фиксированный шаг и клонировать batch на следующую неделю или месяц. - **🧩 Bulk-операции для Batch Sessions**: ГМ может обновить общий title/link, перенести всю пачку на фиксированный шаг и клонировать batch на следующую неделю или месяц.
- **🔕 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`. - **🔕 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`.
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди. - **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
@@ -125,6 +127,20 @@ docker compose up -d
Строка `Мест:` необязательна. Если она указана, игроки сверх лимита попадут в лист ожидания, а ГМ сможет повысить первого ожидающего через кнопку в Telegram или Web Dashboard. Строка `Мест:` необязательна. Если она указана, игроки сверх лимита попадут в лист ожидания, а ГМ сможет повысить первого ожидающего через кнопку в Telegram или Web Dashboard.
Для регулярной кампании можно не перечислять все даты вручную. Укажите одну строку `Время:`, количество игр и интервал в днях:
```text
/newsession
Название: Kingmaker
Время: 30.04.2026 19:30
Игр: 6
Интервал: 7
Мест: 5
Ссылка: https://discord.gg/invite-link
```
Бот создаст 6 игр с недельным шагом. Вместо `Игр:` также принимается `Сессий:` или `Повторов:`, вместо `Интервал:``Шаг:`.
Игрок может самостоятельно снять запись кнопкой `🚪 Выйти` в сообщении расписания. Если он был в основном составе и в листе ожидания есть игроки, бот автоматически переводит первого ожидающего в основной состав и обновляет сообщение пачки. Игрок может самостоятельно снять запись кнопкой `🚪 Выйти` в сообщении расписания. Если он был в основном составе и в листе ожидания есть игроки, бот автоматически переводит первого ожидающего в основной состав и обновляет сообщение пачки.
### Делегирование управления ### Делегирование управления
@@ -143,12 +159,14 @@ Owner или co-GM нажимает кнопку `⏰ Перенести` у н
### Bulk-операции в Web Dashboard ### Bulk-операции в Web Dashboard
На странице группы Web Dashboard показывает отдельный блок для каждой пачки игр. Owner и co-GM могут: На странице группы Web Dashboard показывает отдельный блок для каждой пачки игр. Owner и co-GM могут:
- сохранить шаблон кампании с названием, ссылкой, количеством игр, интервалом, лимитом мест и режимом уведомлений;
- создать новый batch из шаблона, выбрав только первую дату расписания;
- обновить общий `title` и `link` сразу у всех сессий batch; - обновить общий `title` и `link` сразу у всех сессий batch;
- выбрать режим уведомлений: дублировать важные сообщения игрокам в личку или оставить только групповые уведомления; - выбрать режим уведомлений: дублировать важные сообщения игрокам в личку или оставить только групповые уведомления;
- перенести пачку, задав новую первую дату и фиксированный шаг между играми в днях; - перенести пачку, задав новую первую дату и фиксированный шаг между играми в днях;
- клонировать batch на следующую неделю или следующий календарный месяц. - клонировать batch на следующую неделю или следующий календарный месяц.
После редактирования или переноса исходное Telegram-сообщение расписания перерисовывается. При клонировании создаётся новая пачка с новым Telegram-сообщением и пустым составом игроков. После создания из шаблона или клонирования появляется новая пачка с новым Telegram-сообщением и пустым составом игроков. После редактирования или переноса исходное Telegram-сообщение расписания перерисовывается.
Если включён режим `В группе и в личку`, бот дополнительно отправляет игрокам персональные сообщения о RSVP за 24 часа, напоминание за 1 час, ссылку перед стартом, отмену и перенос. Если Telegram не позволяет написать игроку в ЛС, бот логирует ошибку и продолжает отправку остальным участникам. Если включён режим `В группе и в личку`, бот дополнительно отправляет игрокам персональные сообщения о RSVP за 24 часа, напоминание за 1 час, ссылку перед стартом, отмену и перенос. Если Telegram не позволяет написать игроку в ЛС, бот логирует ошибку и продолжает отправку остальным участникам.
+2 -2
View File
@@ -17,7 +17,7 @@ services:
retries: 10 retries: 10
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.7.0 image: git.codeanddice.ru/toutsu/gmrelay-bot:1.8.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -29,7 +29,7 @@ services:
- gmrelay - gmrelay
web: web:
image: git.codeanddice.ru/toutsu/gmrelay-web:1.7.0 image: git.codeanddice.ru/toutsu/gmrelay-web:1.8.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -42,11 +42,19 @@ public sealed class CreateSessionHandler(
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
} }
foreach (var recurringInput in parseResult.InvalidRecurringInputs)
{
await botClient.SendMessage(
message.Chat.Id,
$"⚠️ Предупреждение: некорректный повтор расписания '{recurringInput}'. Укажите число игр 1-52 и шаг 1-365 дней.",
cancellationToken: cancellationToken);
}
if (!parseResult.IsValid) if (!parseResult.IsValid)
{ {
await botClient.SendMessage( await botClient.SendMessage(
chatId: message.Chat.Id, 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); cancellationToken: cancellationToken);
return; return;
} }
@@ -9,17 +9,21 @@ internal sealed record NewSessionParseResult(
IReadOnlyList<DateTimeOffset> ScheduledTimes, IReadOnlyList<DateTimeOffset> ScheduledTimes,
IReadOnlyList<string> PastTimeInputs, IReadOnlyList<string> PastTimeInputs,
IReadOnlyList<string> InvalidTimeInputs, IReadOnlyList<string> InvalidTimeInputs,
IReadOnlyList<string> InvalidSeatLimitInputs) IReadOnlyList<string> InvalidSeatLimitInputs,
IReadOnlyList<string> InvalidRecurringInputs)
{ {
public bool IsValid => public bool IsValid =>
!string.IsNullOrWhiteSpace(Title) && !string.IsNullOrWhiteSpace(Title) &&
!string.IsNullOrWhiteSpace(Link) && !string.IsNullOrWhiteSpace(Link) &&
ScheduledTimes.Count > 0 && ScheduledTimes.Count > 0 &&
InvalidSeatLimitInputs.Count == 0; InvalidSeatLimitInputs.Count == 0 &&
InvalidRecurringInputs.Count == 0;
} }
internal static class NewSessionCommandParser 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 TitlePrefix = "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435:";
private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:"; private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:";
private const string LinkPrefix = "\u0421\u0441\u044b\u043b\u043a\u0430:"; private const string LinkPrefix = "\u0421\u0441\u044b\u043b\u043a\u0430:";
@@ -29,16 +33,30 @@ internal static class NewSessionCommandParser
"\u041b\u0438\u043c\u0438\u0442:", "\u041b\u0438\u043c\u0438\u0442:",
"\u041c\u0430\u043a\u0441\u0438\u043c\u0443\u043c:" "\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) public static NewSessionParseResult Parse(string? text, DateTimeOffset nowUtc)
{ {
string? title = null; string? title = null;
string? link = null; string? link = null;
int? maxPlayers = null; int? maxPlayers = null;
int? recurringCount = null;
var recurringIntervalDays = 7;
var scheduledTimes = new List<DateTimeOffset>(); var scheduledTimes = new List<DateTimeOffset>();
var pastTimeInputs = new List<string>(); var pastTimeInputs = new List<string>();
var invalidTimeInputs = new List<string>(); var invalidTimeInputs = new List<string>();
var invalidSeatLimitInputs = new List<string>(); var invalidSeatLimitInputs = new List<string>();
var invalidRecurringInputs = new List<string>();
foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries)) foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries))
{ {
@@ -71,6 +89,42 @@ internal static class NewSessionCommandParser
continue; 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)) if (!line.StartsWith(TimePrefix, StringComparison.OrdinalIgnoreCase))
{ {
continue; continue;
@@ -92,6 +146,14 @@ internal static class NewSessionCommandParser
scheduledTimes.Add(scheduledAt); 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( return new NewSessionParseResult(
title, title,
link, link,
@@ -99,6 +161,7 @@ internal static class NewSessionCommandParser
scheduledTimes, scheduledTimes,
pastTimeInputs, pastTimeInputs,
invalidTimeInputs, invalidTimeInputs,
invalidSeatLimitInputs); invalidSeatLimitInputs,
invalidRecurringInputs);
} }
} }
@@ -218,6 +218,10 @@ public sealed class UpdateRouter(
Мест: 4 Мест: 4
Ссылка: https://link Ссылка: https://link
Для регулярного расписания можно указать одну дату:
Игр: 4
Интервал: 7
/listsessions список предстоящих сессий /listsessions список предстоящих сессий
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти». Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
Owner и co-GM могут переносить сессии кнопкой «Перенести»: бот попросит 2-3 варианта времени и дедлайн голосования. 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);
@@ -85,6 +85,83 @@
</div> </div>
} }
@if (campaignTemplates is not null)
{
<div class="glass-card campaign-template-panel animate-slide-up">
<div class="batch-bulk-header">
<div>
<h3>Шаблоны кампаний</h3>
<p>@campaignTemplateModels.Count сохранённых</p>
</div>
<span class="status-badge status-info">Template</span>
</div>
<EditForm Model="@campaignTemplateModel" OnValidSubmit="CreateCampaignTemplate">
<div class="campaign-template-fields">
<div class="gm-form-group">
<label class="gm-form-label">Название шаблона</label>
<InputText @bind-Value="campaignTemplateModel.Name" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Название кампании</label>
<InputText @bind-Value="campaignTemplateModel.Title" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Ссылка</label>
<InputText @bind-Value="campaignTemplateModel.JoinLink" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Игр</label>
<InputNumber @bind-Value="campaignTemplateModel.SessionCount" class="gm-form-control" min="1" max="52" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Интервал, дней</label>
<InputNumber @bind-Value="campaignTemplateModel.IntervalDays" class="gm-form-control" min="1" max="365" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Мест</label>
<InputNumber @bind-Value="campaignTemplateModel.MaxPlayers" class="gm-form-control" min="1" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Уведомления</label>
<select @bind="campaignTemplateModel.NotificationMode" class="gm-form-control">
<option value="@SessionNotificationModeExtensions.GroupAndDirectValue">В группе и в личку</option>
<option value="@SessionNotificationModeExtensions.GroupOnlyValue">Только в группе</option>
</select>
</div>
</div>
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isCreatingTemplate">
@(isCreatingTemplate ? "⏳ Сохраняем..." : "💾 Сохранить шаблон")
</button>
</EditForm>
@if (campaignTemplateModels.Count > 0)
{
<div class="campaign-template-list">
@foreach (var template in campaignTemplateModels)
{
<div class="campaign-template-row">
<div class="campaign-template-info">
<h3>@template.Name</h3>
<p>@FormatTemplateSummary(template)</p>
</div>
<div class="campaign-template-actions">
<input type="datetime-local" @bind="template.FirstScheduledAtLocal" class="gm-form-control" />
<button type="button" class="btn-gm btn-gm-success" disabled="@IsTemplateBusy(template)" @onclick="() => CreateBatchFromTemplate(template)">
@(processingTemplateId == template.Id ? "⏳ Создаём..." : "📅 Создать batch")
</button>
<button type="button" class="btn-gm btn-gm-danger" disabled="@IsTemplateBusy(template)" @onclick="() => DeleteCampaignTemplate(template)">
@(deletingTemplateId == template.Id ? "⏳ Удаляем..." : "Удалить")
</button>
</div>
</div>
}
</div>
}
</div>
}
@if (sessions == null) @if (sessions == null)
{ {
<div class="glass-card" style="padding: 2rem;"> <div class="glass-card" style="padding: 2rem;">
@@ -263,16 +340,22 @@
@code { @code {
[Parameter] public Guid GroupId { get; set; } [Parameter] public Guid GroupId { get; set; }
private List<WebSession>? sessions; private List<WebSession>? sessions;
private List<WebCampaignTemplate>? campaignTemplates;
private WebGroupManagement? groupManagement; private WebGroupManagement? groupManagement;
private List<BatchBulkEditModel> batchModels = []; private List<BatchBulkEditModel> batchModels = [];
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
private Guid? promotingSessionId; private Guid? promotingSessionId;
private Guid? processingBatchId; private Guid? processingBatchId;
private Guid? processingTemplateId;
private Guid? deletingTemplateId;
private long? removingCoGmId; private long? removingCoGmId;
private bool isAddingCoGm; private bool isAddingCoGm;
private bool isCreatingTemplate;
private long telegramId; private long telegramId;
private string? errorMessage; private string? errorMessage;
private string? successMessage; private string? successMessage;
private CoGmEditModel coGmModel = new(); private CoGmEditModel coGmModel = new();
private CampaignTemplateEditModel campaignTemplateModel = new();
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -302,7 +385,15 @@
return; return;
} }
campaignTemplates = await SessionService.GetCampaignTemplatesForGmAsync(GroupId, telegramId);
if (campaignTemplates is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
RebuildBatchModels(); RebuildBatchModels();
RebuildCampaignTemplateModels();
} }
private async Task AddCoGm() private async Task AddCoGm()
@@ -497,6 +588,110 @@
} }
} }
private async Task CreateCampaignTemplate()
{
errorMessage = null;
successMessage = null;
if (!ValidateCampaignTemplate(campaignTemplateModel))
{
errorMessage = "Шаблон должен иметь название, ссылку, 1-52 игр, шаг 1-365 дней и положительный лимит мест, если он указан.";
return;
}
isCreatingTemplate = true;
try
{
await SessionService.CreateCampaignTemplateForGmAsync(
GroupId,
telegramId,
new CreateCampaignTemplateRequest(
campaignTemplateModel.Name,
campaignTemplateModel.Title,
campaignTemplateModel.JoinLink,
campaignTemplateModel.SessionCount,
campaignTemplateModel.IntervalDays,
campaignTemplateModel.MaxPlayers,
SessionNotificationModeExtensions.FromDatabaseValue(campaignTemplateModel.NotificationMode)));
campaignTemplateModel = new();
successMessage = "Шаблон кампании сохранён.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось сохранить шаблон: " + ex.Message;
}
finally
{
isCreatingTemplate = false;
}
}
private async Task CreateBatchFromTemplate(CampaignTemplateUsageModel template)
{
errorMessage = null;
successMessage = null;
processingTemplateId = template.Id;
try
{
var utcTime = new DateTimeOffset(template.FirstScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
if (utcTime <= DateTime.UtcNow)
{
errorMessage = "Первая дата batch должна быть в будущем.";
return;
}
var createdBatch = await SessionService.CreateBatchFromCampaignTemplateForGmAsync(template.Id, telegramId, utcTime);
successMessage = $"Batch создан из шаблона: {createdBatch.SessionCount} игр.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось создать batch из шаблона: " + ex.Message;
}
finally
{
processingTemplateId = null;
}
}
private async Task DeleteCampaignTemplate(CampaignTemplateUsageModel template)
{
errorMessage = null;
successMessage = null;
deletingTemplateId = template.Id;
try
{
await SessionService.DeleteCampaignTemplateForGmAsync(template.Id, telegramId);
successMessage = "Шаблон кампании удалён.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось удалить шаблон: " + ex.Message;
}
finally
{
deletingTemplateId = null;
}
}
private void RebuildBatchModels() private void RebuildBatchModels()
{ {
batchModels = sessions? batchModels = sessions?
@@ -523,6 +718,27 @@
.ToList() ?? []; .ToList() ?? [];
} }
private void RebuildCampaignTemplateModels()
{
var defaultStart = DateTime.UtcNow.AddDays(7).ToMoscow();
campaignTemplateModels = campaignTemplates?
.OrderByDescending(template => template.UpdatedAt)
.ThenBy(template => template.Name)
.Select(template => new CampaignTemplateUsageModel
{
Id = template.Id,
Name = template.Name,
Title = template.Title,
JoinLink = template.JoinLink,
SessionCount = template.SessionCount,
IntervalDays = template.IntervalDays,
MaxPlayers = template.MaxPlayers,
NotificationMode = template.NotificationMode,
FirstScheduledAtLocal = defaultStart
})
.ToList() ?? [];
}
private static bool ValidateBatchDetails(BatchBulkEditModel batch) private static bool ValidateBatchDetails(BatchBulkEditModel batch)
{ {
batch.Title = batch.Title.Trim(); batch.Title = batch.Title.Trim();
@@ -530,8 +746,29 @@
return batch.Title.Length > 0 && batch.JoinLink.Length > 0; return batch.Title.Length > 0 && batch.JoinLink.Length > 0;
} }
private static bool ValidateCampaignTemplate(CampaignTemplateEditModel template)
{
template.Name = template.Name.Trim();
template.Title = template.Title.Trim();
template.JoinLink = template.JoinLink.Trim();
if (template.MaxPlayers.HasValue && template.MaxPlayers.Value <= 0)
{
return false;
}
return template.Name.Length > 0 &&
template.Title.Length > 0 &&
template.JoinLink.Length > 0 &&
template.SessionCount is >= 1 and <= 52 &&
template.IntervalDays is >= 1 and <= 365;
}
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId; private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
private bool IsTemplateBusy(CampaignTemplateUsageModel template) =>
processingTemplateId == template.Id || deletingTemplateId == template.Id;
private string CurrentUserRole => private string CurrentUserRole =>
groupManagement?.Managers.FirstOrDefault(manager => manager.TelegramId == telegramId)?.Role groupManagement?.Managers.FirstOrDefault(manager => manager.TelegramId == telegramId)?.Role
?? GroupManagerRoleExtensions.CoGmValue; ?? GroupManagerRoleExtensions.CoGmValue;
@@ -577,6 +814,22 @@
private static string FormatBatchSummary(BatchBulkEditModel batch) => private static string FormatBatchSummary(BatchBulkEditModel batch) =>
$"{batch.SessionCount} игр · {FormatLocalMoscow(batch.FirstScheduledAtLocal)} — {FormatLocalMoscow(batch.LastScheduledAtLocal)}"; $"{batch.SessionCount} игр · {FormatLocalMoscow(batch.FirstScheduledAtLocal)} — {FormatLocalMoscow(batch.LastScheduledAtLocal)}";
private static string FormatTemplateSummary(CampaignTemplateUsageModel template)
{
var seats = template.MaxPlayers.HasValue
? $"{template.MaxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)} мест"
: "без лимита";
return $"{template.Title} · {template.SessionCount} игр · каждые {template.IntervalDays} дн. · {seats} · {FormatNotificationMode(template.NotificationMode)}";
}
private static string FormatNotificationMode(string notificationMode) =>
SessionNotificationModeExtensions.FromDatabaseValue(notificationMode) switch
{
SessionNotificationMode.GroupOnly => "только группа",
_ => "группа и личка"
};
private static string FormatLocalMoscow(DateTime localMoscow) => private static string FormatLocalMoscow(DateTime localMoscow) =>
localMoscow.ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU")); localMoscow.ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"));
@@ -611,6 +864,30 @@
public string CloneInterval { get; set; } = "week"; public string CloneInterval { get; set; } = "week";
} }
private sealed class CampaignTemplateEditModel
{
public string Name { get; set; } = "";
public string Title { get; set; } = "";
public string JoinLink { get; set; } = "";
public int SessionCount { get; set; } = 6;
public int IntervalDays { get; set; } = 7;
public int? MaxPlayers { get; set; }
public string NotificationMode { get; set; } = SessionNotificationModeExtensions.GroupAndDirectValue;
}
private sealed class CampaignTemplateUsageModel
{
public Guid Id { get; init; }
public string Name { get; init; } = "";
public string Title { get; init; } = "";
public string JoinLink { get; init; } = "";
public int SessionCount { get; init; }
public int IntervalDays { get; init; }
public int? MaxPlayers { get; init; }
public string NotificationMode { get; init; } = SessionNotificationModeExtensions.GroupAndDirectValue;
public DateTime FirstScheduledAtLocal { get; set; } = DateTime.Now;
}
private sealed class CoGmEditModel private sealed class CoGmEditModel
{ {
public long? TelegramId { get; set; } public long? TelegramId { get; set; }
@@ -128,6 +128,60 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
return await sessionStore.CloneBatchAsync(batchId, batch.GroupId, interval); return await sessionStore.CloneBatchAsync(batchId, batch.GroupId, interval);
} }
public async Task<List<WebCampaignTemplate>?> GetCampaignTemplatesForGmAsync(Guid groupId, long gmId)
{
if (!await GroupBelongsToGmAsync(groupId, gmId))
{
return null;
}
return await sessionStore.GetCampaignTemplatesAsync(groupId);
}
public async Task<WebCampaignTemplate> CreateCampaignTemplateForGmAsync(
Guid groupId,
long gmId,
CreateCampaignTemplateRequest request)
{
if (!await GroupBelongsToGmAsync(groupId, gmId))
{
throw new SessionAccessDeniedException(groupId, gmId);
}
var normalizedRequest = NormalizeCampaignTemplateRequest(request);
return await sessionStore.CreateCampaignTemplateAsync(groupId, normalizedRequest);
}
public async Task DeleteCampaignTemplateForGmAsync(Guid templateId, long gmId)
{
var template = await sessionStore.GetCampaignTemplateAsync(templateId);
if (template is null || !await GroupBelongsToGmAsync(template.GroupId, gmId))
{
throw new SessionAccessDeniedException(templateId, gmId);
}
await sessionStore.DeleteCampaignTemplateAsync(templateId, template.GroupId);
}
public async Task<WebSessionBatch> CreateBatchFromCampaignTemplateForGmAsync(
Guid templateId,
long gmId,
DateTime firstScheduledAt)
{
if (firstScheduledAt <= DateTime.UtcNow)
{
throw new ArgumentOutOfRangeException(nameof(firstScheduledAt), firstScheduledAt, "First scheduled time must be in the future.");
}
var template = await sessionStore.GetCampaignTemplateAsync(templateId);
if (template is null || !await GroupBelongsToGmAsync(template.GroupId, gmId))
{
throw new SessionAccessDeniedException(templateId, gmId);
}
return await sessionStore.CreateBatchFromTemplateAsync(templateId, template.GroupId, firstScheduledAt);
}
public async Task AddCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername) public async Task AddCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername)
{ {
if (coGmTelegramId <= 0) if (coGmTelegramId <= 0)
@@ -169,4 +223,48 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
{ {
return await sessionStore.IsGroupManagerAsync(groupId, gmId); return await sessionStore.IsGroupManagerAsync(groupId, gmId);
} }
private static CreateCampaignTemplateRequest NormalizeCampaignTemplateRequest(CreateCampaignTemplateRequest request)
{
var name = request.Name.Trim();
var title = request.Title.Trim();
var joinLink = request.JoinLink.Trim();
if (name.Length == 0)
{
throw new ArgumentException("Template name must not be empty.", nameof(request));
}
if (title.Length == 0)
{
throw new ArgumentException("Session title must not be empty.", nameof(request));
}
if (joinLink.Length == 0)
{
throw new ArgumentException("Join link must not be empty.", nameof(request));
}
if (request.SessionCount is < 1 or > 52)
{
throw new ArgumentOutOfRangeException(nameof(request), request.SessionCount, "Session count must be between 1 and 52.");
}
if (request.IntervalDays is < 1 or > 365)
{
throw new ArgumentOutOfRangeException(nameof(request), request.IntervalDays, "Interval must be between 1 and 365 days.");
}
if (request.MaxPlayers is <= 0)
{
throw new ArgumentOutOfRangeException(nameof(request), request.MaxPlayers, "Seat limit must be greater than zero.");
}
return request with
{
Name = name,
Title = title,
JoinLink = joinLink
};
}
} }
@@ -18,8 +18,33 @@ public sealed record WebSessionBatch(
int SessionCount, int SessionCount,
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue); string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue);
public sealed record WebCampaignTemplate(
Guid Id,
Guid GroupId,
string Name,
string Title,
string JoinLink,
int SessionCount,
int IntervalDays,
int? MaxPlayers,
string NotificationMode,
DateTime CreatedAt,
DateTime UpdatedAt);
public sealed record CreateCampaignTemplateRequest(
string Name,
string Title,
string JoinLink,
int SessionCount,
int IntervalDays,
int? MaxPlayers,
SessionNotificationMode NotificationMode);
public static class BatchSchedulePlanner public static class BatchSchedulePlanner
{ {
private const int MaxTemplateSessionCount = 52;
private const int MaxTemplateIntervalDays = 365;
public static IReadOnlyList<DateTime> BuildFixedIntervalSchedule( public static IReadOnlyList<DateTime> BuildFixedIntervalSchedule(
IEnumerable<DateTime> currentSchedule, IEnumerable<DateTime> currentSchedule,
DateTime firstScheduledAt, DateTime firstScheduledAt,
@@ -36,6 +61,26 @@ public static class BatchSchedulePlanner
.ToList(); .ToList();
} }
public static IReadOnlyList<DateTime> BuildRecurringSchedule(
DateTime firstScheduledAt,
int sessionCount,
int intervalDays)
{
if (sessionCount is < 1 or > MaxTemplateSessionCount)
{
throw new ArgumentOutOfRangeException(nameof(sessionCount), sessionCount, "Session count must be between 1 and 52.");
}
if (intervalDays is < 1 or > MaxTemplateIntervalDays)
{
throw new ArgumentOutOfRangeException(nameof(intervalDays), intervalDays, "Interval must be between 1 and 365 days.");
}
return Enumerable.Range(0, sessionCount)
.Select(index => firstScheduledAt.AddDays(intervalDays * index))
.ToList();
}
public static DateTime ShiftForClone(DateTime scheduledAt, BatchCloneInterval interval) => public static DateTime ShiftForClone(DateTime scheduledAt, BatchCloneInterval interval) =>
interval switch interval switch
{ {
@@ -18,6 +18,11 @@ public interface ISessionStore
Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode); Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode);
Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays); Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays);
Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval); Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval);
Task<List<WebCampaignTemplate>> GetCampaignTemplatesAsync(Guid groupId);
Task<WebCampaignTemplate?> GetCampaignTemplateAsync(Guid templateId);
Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request);
Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId);
Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt);
Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername); Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername);
Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId); Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId);
} }
+208
View File
@@ -63,6 +63,7 @@ internal sealed record WebBatchSessionRow(
long TelegramChatId, long TelegramChatId,
int? ThreadId, int? ThreadId,
string NotificationMode); string NotificationMode);
internal sealed record WebTemplateGroupDto(long TelegramChatId);
public sealed class SessionService( public sealed class SessionService(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
@@ -753,6 +754,213 @@ public sealed class SessionService(
sourceSessions[0].NotificationMode); sourceSessions[0].NotificationMode);
} }
public async Task<List<WebCampaignTemplate>> GetCampaignTemplatesAsync(Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebCampaignTemplate>(
"""
SELECT id AS Id,
group_id AS GroupId,
name AS Name,
title AS Title,
join_link AS JoinLink,
session_count AS SessionCount,
interval_days AS IntervalDays,
max_players AS MaxPlayers,
notification_mode AS NotificationMode,
created_at AS CreatedAt,
updated_at AS UpdatedAt
FROM campaign_templates
WHERE group_id = @GroupId
ORDER BY created_at DESC, name
""",
new { GroupId = groupId })).ToList();
}
public async Task<WebCampaignTemplate?> GetCampaignTemplateAsync(Guid templateId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleOrDefaultAsync<WebCampaignTemplate>(
"""
SELECT id AS Id,
group_id AS GroupId,
name AS Name,
title AS Title,
join_link AS JoinLink,
session_count AS SessionCount,
interval_days AS IntervalDays,
max_players AS MaxPlayers,
notification_mode AS NotificationMode,
created_at AS CreatedAt,
updated_at AS UpdatedAt
FROM campaign_templates
WHERE id = @TemplateId
""",
new { TemplateId = templateId });
}
public async Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleAsync<WebCampaignTemplate>(
"""
INSERT INTO campaign_templates (
group_id,
name,
title,
join_link,
session_count,
interval_days,
max_players,
notification_mode
)
VALUES (
@GroupId,
@Name,
@Title,
@JoinLink,
@SessionCount,
@IntervalDays,
@MaxPlayers,
@NotificationMode
)
ON CONFLICT (group_id, name) DO UPDATE
SET title = EXCLUDED.title,
join_link = EXCLUDED.join_link,
session_count = EXCLUDED.session_count,
interval_days = EXCLUDED.interval_days,
max_players = EXCLUDED.max_players,
notification_mode = EXCLUDED.notification_mode,
updated_at = now()
RETURNING id AS Id,
group_id AS GroupId,
name AS Name,
title AS Title,
join_link AS JoinLink,
session_count AS SessionCount,
interval_days AS IntervalDays,
max_players AS MaxPlayers,
notification_mode AS NotificationMode,
created_at AS CreatedAt,
updated_at AS UpdatedAt
""",
new
{
GroupId = groupId,
request.Name,
request.Title,
request.JoinLink,
request.SessionCount,
request.IntervalDays,
request.MaxPlayers,
NotificationMode = request.NotificationMode.ToDatabaseValue()
});
}
public async Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
await conn.ExecuteAsync(
"DELETE FROM campaign_templates WHERE id = @TemplateId AND group_id = @GroupId",
new { TemplateId = templateId, GroupId = groupId });
}
public async Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
var template = await conn.QuerySingleOrDefaultAsync<WebCampaignTemplate>(
"""
SELECT id AS Id,
group_id AS GroupId,
name AS Name,
title AS Title,
join_link AS JoinLink,
session_count AS SessionCount,
interval_days AS IntervalDays,
max_players AS MaxPlayers,
notification_mode AS NotificationMode,
created_at AS CreatedAt,
updated_at AS UpdatedAt
FROM campaign_templates
WHERE id = @TemplateId
AND group_id = @GroupId
FOR UPDATE
""",
new { TemplateId = templateId, GroupId = groupId },
transaction);
if (template is null)
{
throw new SessionAccessDeniedException(templateId, 0);
}
var group = await conn.QuerySingleOrDefaultAsync<WebTemplateGroupDto>(
"SELECT telegram_chat_id AS TelegramChatId FROM game_groups WHERE id = @GroupId",
new { GroupId = groupId },
transaction);
if (group is null)
{
throw new SessionAccessDeniedException(groupId, 0);
}
var schedule = BatchSchedulePlanner.BuildRecurringSchedule(
firstScheduledAt,
template.SessionCount,
template.IntervalDays);
var batchId = Guid.NewGuid();
var renderedSessions = new List<SessionBatchDto>();
foreach (var scheduledAt in schedule)
{
var sessionId = await conn.ExecuteScalarAsync<Guid>(
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, notification_mode)
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @MaxPlayers, @NotificationMode)
RETURNING id
""",
new
{
BatchId = batchId,
GroupId = groupId,
template.Title,
template.JoinLink,
ScheduledAt = scheduledAt,
Status = SessionStatus.Planned,
template.MaxPlayers,
template.NotificationMode
},
transaction);
renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, template.MaxPlayers));
}
await transaction.CommitAsync();
var renderResult = SessionBatchRenderer.Render(template.Title, renderedSessions, Array.Empty<ParticipantBatchDto>());
var batchMessage = await bot.SendMessage(
chatId: group.TelegramChatId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup);
await conn.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MessageId WHERE batch_id = @BatchId",
new { MessageId = batchMessage.MessageId, BatchId = batchId });
return new WebSessionBatch(
batchId,
groupId,
template.Title,
template.JoinLink,
renderedSessions.Min(session => session.ScheduledAt),
renderedSessions.Max(session => session.ScheduledAt),
renderedSessions.Count,
template.NotificationMode);
}
private async Task<List<WebDirectNotificationRecipient>> LoadSessionDirectRecipientsAsync( private async Task<List<WebDirectNotificationRecipient>> LoadSessionDirectRecipientsAsync(
Npgsql.NpgsqlConnection conn, Npgsql.NpgsqlConnection conn,
Guid sessionId) Guid sessionId)
+56 -3
View File
@@ -1,5 +1,5 @@
/* ============================================ /* ============================================
GM-Relay Design System v1.7.0 GM-Relay Design System v1.8.0
Dark RPG Dashboard Theme Dark RPG Dashboard Theme
============================================ */ ============================================ */
@@ -618,6 +618,55 @@ select option {
white-space: nowrap; white-space: nowrap;
} }
/* === Campaign templates === */
.campaign-template-panel {
margin-bottom: 1.5rem;
}
.campaign-template-fields {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.campaign-template-list {
margin-top: 1rem;
border-top: 1px solid var(--border-color);
}
.campaign-template-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, auto);
gap: 1rem;
align-items: center;
padding: 1rem 0;
border-bottom: 1px solid var(--border-color);
}
.campaign-template-row:last-child {
border-bottom: none;
padding-bottom: 0;
}
.campaign-template-info h3 {
font-size: 0.9375rem;
margin-bottom: 0.25rem;
overflow-wrap: anywhere;
}
.campaign-template-info p {
margin: 0;
color: var(--text-muted);
font-size: 0.8125rem;
}
.campaign-template-actions {
display: grid;
grid-template-columns: minmax(190px, 1fr) auto auto;
gap: 0.75rem;
align-items: center;
}
/* === Animations === */ /* === Animations === */
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); } from { opacity: 0; transform: translateY(8px); }
@@ -838,11 +887,15 @@ select option {
} }
.batch-bulk-fields, .batch-bulk-fields,
.batch-clone-row { .batch-clone-row,
.campaign-template-fields,
.campaign-template-row,
.campaign-template-actions {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.batch-clone-row .btn-gm { .batch-clone-row .btn-gm,
.campaign-template-actions .btn-gm {
justify-content: center; justify-content: center;
width: 100%; width: 100%;
} }
@@ -33,6 +33,32 @@ public sealed class NewSessionCommandParserTests
Assert.Empty(result.InvalidTimeInputs); Assert.Empty(result.InvalidTimeInputs);
} }
[Fact]
public void Parse_ShouldExpandRecurringSchedule_WhenRepeatCountAndIntervalProvided()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
/newsession
Название: Kingmaker
Время: 30.04.2026 19:30
Игр: 4
Интервал: 14
Ссылка: https://example.test/kingmaker
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Equal(
[
new DateTimeOffset(2026, 4, 30, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 14, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 28, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 6, 11, 16, 30, 0, TimeSpan.Zero)
],
result.ScheduledTimes);
}
[Fact] [Fact]
public void Parse_ShouldCollectPastAndInvalidTimes() public void Parse_ShouldCollectPastAndInvalidTimes()
{ {
@@ -460,13 +460,153 @@ public sealed class AuthorizedSessionServiceTests
Assert.Equal(BatchCloneInterval.NextWeek, store.LastCloneInterval); Assert.Equal(BatchCloneInterval.NextWeek, store.LastCloneInterval);
} }
[Fact]
public async Task CreateCampaignTemplateForGmAsync_CreatesTemplate_WhenGroupBelongsToGm()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
]);
var service = new AuthorizedSessionService(store);
await service.CreateCampaignTemplateForGmAsync(
groupId,
gmId,
new CreateCampaignTemplateRequest(
" Weekly arc ",
" Kingmaker ",
" https://example.test/kingmaker ",
SessionCount: 6,
IntervalDays: 7,
MaxPlayers: 5,
SessionNotificationMode.GroupOnly));
Assert.True(store.CreateCampaignTemplateCalled);
Assert.Equal(groupId, store.LastCreatedCampaignTemplateGroupId);
Assert.Equal("Weekly arc", store.LastCreatedCampaignTemplateRequest?.Name);
Assert.Equal("Kingmaker", store.LastCreatedCampaignTemplateRequest?.Title);
Assert.Equal("https://example.test/kingmaker", store.LastCreatedCampaignTemplateRequest?.JoinLink);
Assert.Equal(6, store.LastCreatedCampaignTemplateRequest?.SessionCount);
Assert.Equal(7, store.LastCreatedCampaignTemplateRequest?.IntervalDays);
Assert.Equal(5, store.LastCreatedCampaignTemplateRequest?.MaxPlayers);
Assert.Equal(SessionNotificationMode.GroupOnly, store.LastCreatedCampaignTemplateRequest?.NotificationMode);
}
[Fact]
public async Task CreateCampaignTemplateForGmAsync_AllowsNoSeatLimit()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
]);
var service = new AuthorizedSessionService(store);
await service.CreateCampaignTemplateForGmAsync(
groupId,
gmId,
new CreateCampaignTemplateRequest(
"Open table",
"West Marches",
"https://example.test/west",
8,
7,
null,
SessionNotificationMode.GroupAndDirect));
Assert.True(store.CreateCampaignTemplateCalled);
Assert.Null(store.LastCreatedCampaignTemplateRequest?.MaxPlayers);
}
[Fact]
public async Task CreateCampaignTemplateForGmAsync_Throws_WhenGroupBelongsToAnotherGm()
{
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", 2002L)
]);
var service = new AuthorizedSessionService(store);
var action = () => service.CreateCampaignTemplateForGmAsync(
groupId,
1001L,
new CreateCampaignTemplateRequest(
"Weekly arc",
"Kingmaker",
"https://example.test/kingmaker",
SessionCount: 6,
IntervalDays: 7,
MaxPlayers: 5,
SessionNotificationMode.GroupOnly));
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.CreateCampaignTemplateCalled);
}
[Fact]
public async Task CreateBatchFromCampaignTemplateForGmAsync_CreatesBatch_WhenTemplateGroupBelongsToGm()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var templateId = Guid.NewGuid();
var firstScheduledAt = DateTime.UtcNow.AddDays(3);
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
templates:
[
new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow)
]);
var service = new AuthorizedSessionService(store);
await service.CreateBatchFromCampaignTemplateForGmAsync(templateId, gmId, firstScheduledAt);
Assert.True(store.CreateBatchFromTemplateCalled);
Assert.Equal(templateId, store.LastCreatedBatchTemplateId);
Assert.Equal(groupId, store.LastCreatedBatchTemplateGroupId);
Assert.Equal(firstScheduledAt, store.LastCreatedBatchFirstScheduledAt);
}
[Fact]
public async Task CreateBatchFromCampaignTemplateForGmAsync_Throws_WhenTemplateGroupBelongsToAnotherGm()
{
var groupId = Guid.NewGuid();
var templateId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", 2002L)
],
templates:
[
new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow)
]);
var service = new AuthorizedSessionService(store);
var action = () => service.CreateBatchFromCampaignTemplateForGmAsync(templateId, 1001L, DateTime.UtcNow.AddDays(3));
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.CreateBatchFromTemplateCalled);
}
private sealed class FakeSessionStore( private sealed class FakeSessionStore(
IEnumerable<WebGameGroup>? groups = null, IEnumerable<WebGameGroup>? groups = null,
IEnumerable<WebSession>? sessions = null, IEnumerable<WebSession>? sessions = null,
IEnumerable<FakeGroupManager>? managers = null) : ISessionStore IEnumerable<FakeGroupManager>? managers = null,
IEnumerable<WebCampaignTemplate>? templates = null) : ISessionStore
{ {
private readonly Dictionary<Guid, WebGameGroup> groupsById = groups?.ToDictionary(group => group.Id) ?? []; private readonly Dictionary<Guid, WebGameGroup> groupsById = groups?.ToDictionary(group => group.Id) ?? [];
private readonly Dictionary<Guid, WebSession> sessionsById = sessions?.ToDictionary(session => session.Id) ?? []; private readonly Dictionary<Guid, WebSession> sessionsById = sessions?.ToDictionary(session => session.Id) ?? [];
private readonly Dictionary<Guid, WebCampaignTemplate> templatesById = templates?.ToDictionary(template => template.Id) ?? [];
private readonly List<FakeGroupManager> managers = managers?.ToList() ?? []; private readonly List<FakeGroupManager> managers = managers?.ToList() ?? [];
public bool UpdateCalled { get; private set; } public bool UpdateCalled { get; private set; }
@@ -475,6 +615,9 @@ public sealed class AuthorizedSessionServiceTests
public bool UpdateBatchNotificationModeCalled { get; private set; } public bool UpdateBatchNotificationModeCalled { get; private set; }
public bool RescheduleBatchCalled { get; private set; } public bool RescheduleBatchCalled { get; private set; }
public bool CloneBatchCalled { get; private set; } public bool CloneBatchCalled { get; private set; }
public bool CreateCampaignTemplateCalled { get; private set; }
public bool DeleteCampaignTemplateCalled { get; private set; }
public bool CreateBatchFromTemplateCalled { get; private set; }
public bool AddCoGmCalled { get; private set; } public bool AddCoGmCalled { get; private set; }
public bool RemoveCoGmCalled { get; private set; } public bool RemoveCoGmCalled { get; private set; }
public Guid? LastUpdatedSessionId { get; private set; } public Guid? LastUpdatedSessionId { get; private set; }
@@ -499,6 +642,13 @@ public sealed class AuthorizedSessionServiceTests
public Guid? LastClonedBatchId { get; private set; } public Guid? LastClonedBatchId { get; private set; }
public Guid? LastClonedBatchGroupId { get; private set; } public Guid? LastClonedBatchGroupId { get; private set; }
public BatchCloneInterval? LastCloneInterval { get; private set; } public BatchCloneInterval? LastCloneInterval { get; private set; }
public Guid? LastCreatedCampaignTemplateGroupId { get; private set; }
public CreateCampaignTemplateRequest? LastCreatedCampaignTemplateRequest { get; private set; }
public Guid? LastDeletedCampaignTemplateId { get; private set; }
public Guid? LastDeletedCampaignTemplateGroupId { get; private set; }
public Guid? LastCreatedBatchTemplateId { get; private set; }
public Guid? LastCreatedBatchTemplateGroupId { get; private set; }
public DateTime? LastCreatedBatchFirstScheduledAt { get; private set; }
public Guid? LastAddedCoGmGroupId { get; private set; } public Guid? LastAddedCoGmGroupId { get; private set; }
public long? LastAddedCoGmTelegramId { get; private set; } public long? LastAddedCoGmTelegramId { get; private set; }
public string? LastAddedCoGmDisplayName { get; private set; } public string? LastAddedCoGmDisplayName { get; private set; }
@@ -642,6 +792,66 @@ public sealed class AuthorizedSessionServiceTests
1)); 1));
} }
public Task<List<WebCampaignTemplate>> GetCampaignTemplatesAsync(Guid groupId) =>
Task.FromResult(templatesById.Values.Where(template => template.GroupId == groupId).ToList());
public Task<WebCampaignTemplate?> GetCampaignTemplateAsync(Guid templateId)
{
templatesById.TryGetValue(templateId, out var template);
return Task.FromResult(template);
}
public Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request)
{
CreateCampaignTemplateCalled = true;
LastCreatedCampaignTemplateGroupId = groupId;
LastCreatedCampaignTemplateRequest = request;
var template = new WebCampaignTemplate(
Guid.NewGuid(),
groupId,
request.Name,
request.Title,
request.JoinLink,
request.SessionCount,
request.IntervalDays,
request.MaxPlayers,
request.NotificationMode.ToDatabaseValue(),
DateTime.UtcNow,
DateTime.UtcNow);
templatesById[template.Id] = template;
return Task.FromResult(template);
}
public Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId)
{
DeleteCampaignTemplateCalled = true;
LastDeletedCampaignTemplateId = templateId;
LastDeletedCampaignTemplateGroupId = groupId;
templatesById.Remove(templateId);
return Task.CompletedTask;
}
public Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt)
{
CreateBatchFromTemplateCalled = true;
LastCreatedBatchTemplateId = templateId;
LastCreatedBatchTemplateGroupId = groupId;
LastCreatedBatchFirstScheduledAt = firstScheduledAt;
var template = templatesById[templateId];
return Task.FromResult(new WebSessionBatch(
Guid.NewGuid(),
groupId,
template.Title,
template.JoinLink,
firstScheduledAt,
firstScheduledAt.AddDays(template.IntervalDays * (template.SessionCount - 1)),
template.SessionCount,
template.NotificationMode));
}
public Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername) public Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername)
{ {
AddCoGmCalled = true; AddCoGmCalled = true;
@@ -37,6 +37,35 @@ public sealed class BatchSchedulePlannerTests
Assert.Throws<ArgumentOutOfRangeException>(action); Assert.Throws<ArgumentOutOfRangeException>(action);
} }
[Fact]
public void BuildRecurringSchedule_CreatesFixedIntervalScheduleFromFirstDate()
{
var firstScheduledAt = new DateTime(2026, 5, 4, 16, 0, 0, DateTimeKind.Utc);
var result = BatchSchedulePlanner.BuildRecurringSchedule(firstScheduledAt, sessionCount: 3, intervalDays: 14);
Assert.Equal(
[
firstScheduledAt,
firstScheduledAt.AddDays(14),
firstScheduledAt.AddDays(28)
],
result);
}
[Theory]
[InlineData(0, 7)]
[InlineData(53, 7)]
[InlineData(3, 0)]
public void BuildRecurringSchedule_RejectsInvalidTemplateShape(int sessionCount, int intervalDays)
{
var firstScheduledAt = new DateTime(2026, 5, 4, 16, 0, 0, DateTimeKind.Utc);
var action = () => BatchSchedulePlanner.BuildRecurringSchedule(firstScheduledAt, sessionCount, intervalDays);
Assert.Throws<ArgumentOutOfRangeException>(action);
}
[Theory] [Theory]
[InlineData(BatchCloneInterval.NextWeek, 2026, 5, 8)] [InlineData(BatchCloneInterval.NextWeek, 2026, 5, 8)]
[InlineData(BatchCloneInterval.NextMonth, 2026, 6, 1)] [InlineData(BatchCloneInterval.NextMonth, 2026, 6, 1)]