Compare commits
1 Commits
v3.0.4
...
dd0c2d1488
| Author | SHA1 | Date | |
|---|---|---|---|
| dd0c2d1488 |
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 1.13.0
|
VERSION: 1.14.0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>1.13.0</Version>
|
<Version>1.14.0</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||||
|
|
||||||
**Текущая версия:** `v1.10.2`.
|
**Текущая версия:** `v1.14.0`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
- **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch.
|
- **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch.
|
||||||
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
|
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
|
||||||
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему.
|
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему.
|
||||||
- **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр.
|
- **📁 Поддержка Форумов (Telegram Topics)**: Если `/newsession` запущен в теме форума Telegram, расписание и групповые уведомления остаются в этой теме; при запуске из корня форума бот создает отдельную тему и сообщает о необходимости прав admin/Manage Topics, если их не хватает.
|
||||||
- **❌ Управление сессиями**: Owner и назначенные co-GM могут создавать, отменять, удалять и переносить игры из Telegram через `/listsessions`; публичный пост записи показывает только кнопки игроков.
|
- **❌ Управление сессиями**: Owner и назначенные co-GM могут создавать, отменять, удалять и переносить игры из Telegram через `/listsessions`; публичный пост записи показывает только кнопки игроков.
|
||||||
- **🔄 Голосование за перенос**: Быстрый поиск свободного места с через свободное недель и кнопками новых времени и дедлайном.
|
- **🔄 Голосование за перенос**: Быстрый поиск свободного места с через свободное недель и кнопками новых времени и дедлайном.
|
||||||
- **🔔 Уведомления**: Игрок получают за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются.
|
- **🔔 Уведомления**: Игрок получают за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются.
|
||||||
|
|||||||
+2
-2
@@ -17,7 +17,7 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.13.0
|
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.14.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -30,7 +30,7 @@ services:
|
|||||||
- gmrelay
|
- gmrelay
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-web:1.13.0
|
image: git.codeanddice.ru/toutsu/gmrelay-web:1.14.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ internal sealed record SessionContext(
|
|||||||
DateTime ScheduledAt,
|
DateTime ScheduledAt,
|
||||||
string Status,
|
string Status,
|
||||||
long GmTelegramId,
|
long GmTelegramId,
|
||||||
long TelegramChatId);
|
long TelegramChatId,
|
||||||
|
int? ThreadId);
|
||||||
|
|
||||||
internal sealed record ParticipantRsvp(
|
internal sealed record ParticipantRsvp(
|
||||||
long TelegramId,
|
long TelegramId,
|
||||||
@@ -95,7 +96,8 @@ public sealed class HandleRsvpHandler(
|
|||||||
s.scheduled_at AS ScheduledAt,
|
s.scheduled_at AS ScheduledAt,
|
||||||
s.status AS Status,
|
s.status AS Status,
|
||||||
g.gm_telegram_id AS GmTelegramId,
|
g.gm_telegram_id AS GmTelegramId,
|
||||||
g.telegram_chat_id AS TelegramChatId
|
g.telegram_chat_id AS TelegramChatId,
|
||||||
|
s.thread_id AS ThreadId
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE s.id = @SessionId
|
WHERE s.id = @SessionId
|
||||||
@@ -191,6 +193,7 @@ public sealed class HandleRsvpHandler(
|
|||||||
{
|
{
|
||||||
await bot.SendMessage(
|
await bot.SendMessage(
|
||||||
chatId: session.TelegramChatId,
|
chatId: session.TelegramChatId,
|
||||||
|
messageThreadId: session.ThreadId,
|
||||||
text: $"🎉 Игра «{session.Title}» подтверждена! Все участники на месте.",
|
text: $"🎉 Игра «{session.Title}» подтверждена! Все участники на месте.",
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ internal sealed record SessionInfo(
|
|||||||
DateTime ScheduledAt,
|
DateTime ScheduledAt,
|
||||||
Guid GroupId,
|
Guid GroupId,
|
||||||
long TelegramChatId,
|
long TelegramChatId,
|
||||||
|
int? ThreadId,
|
||||||
string NotificationMode);
|
string NotificationMode);
|
||||||
|
|
||||||
internal sealed record ParticipantInfo(
|
internal sealed record ParticipantInfo(
|
||||||
@@ -43,6 +44,7 @@ public sealed class SendConfirmationHandler(
|
|||||||
"""
|
"""
|
||||||
SELECT s.id, s.title, s.scheduled_at AS ScheduledAt, s.group_id AS GroupId,
|
SELECT s.id, s.title, s.scheduled_at AS ScheduledAt, s.group_id AS GroupId,
|
||||||
g.telegram_chat_id AS TelegramChatId,
|
g.telegram_chat_id AS TelegramChatId,
|
||||||
|
s.thread_id AS ThreadId,
|
||||||
s.notification_mode AS NotificationMode
|
s.notification_mode AS NotificationMode
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
@@ -99,6 +101,7 @@ public sealed class SendConfirmationHandler(
|
|||||||
// 4. Send to group
|
// 4. Send to group
|
||||||
var message = await bot.SendMessage(
|
var message = await bot.SendMessage(
|
||||||
chatId: session.TelegramChatId,
|
chatId: session.TelegramChatId,
|
||||||
|
messageThreadId: session.ThreadId,
|
||||||
text: text,
|
text: text,
|
||||||
replyMarkup: keyboard,
|
replyMarkup: keyboard,
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ internal sealed record JoinLinkSession(
|
|||||||
string JoinLink,
|
string JoinLink,
|
||||||
DateTime ScheduledAt,
|
DateTime ScheduledAt,
|
||||||
long TelegramChatId,
|
long TelegramChatId,
|
||||||
|
int? ThreadId,
|
||||||
string NotificationMode);
|
string NotificationMode);
|
||||||
|
|
||||||
internal sealed record ConfirmedPlayer(
|
internal sealed record ConfirmedPlayer(
|
||||||
@@ -42,6 +43,7 @@ public sealed class SendJoinLinkHandler(
|
|||||||
"""
|
"""
|
||||||
SELECT s.id, s.title, s.join_link AS JoinLink, s.scheduled_at AS ScheduledAt,
|
SELECT s.id, s.title, s.join_link AS JoinLink, s.scheduled_at AS ScheduledAt,
|
||||||
g.telegram_chat_id AS TelegramChatId,
|
g.telegram_chat_id AS TelegramChatId,
|
||||||
|
s.thread_id AS ThreadId,
|
||||||
s.notification_mode AS NotificationMode
|
s.notification_mode AS NotificationMode
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
@@ -94,6 +96,7 @@ public sealed class SendJoinLinkHandler(
|
|||||||
// 4. Send
|
// 4. Send
|
||||||
var message = await bot.SendMessage(
|
var message = await bot.SendMessage(
|
||||||
chatId: session.TelegramChatId,
|
chatId: session.TelegramChatId,
|
||||||
|
messageThreadId: session.ThreadId,
|
||||||
text: text,
|
text: text,
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public sealed record CancelSessionCommand(
|
|||||||
long TelegramUserId,
|
long TelegramUserId,
|
||||||
string CallbackQueryId,
|
string CallbackQueryId,
|
||||||
long ChatId,
|
long ChatId,
|
||||||
|
int? MessageThreadId,
|
||||||
int MessageId);
|
int MessageId);
|
||||||
|
|
||||||
// DTOs for AOT compilation
|
// DTOs for AOT compilation
|
||||||
@@ -119,7 +120,12 @@ public sealed class CancelSessionHandler(
|
|||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия отменена!", cancellationToken: ct);
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия отменена!", cancellationToken: ct);
|
||||||
|
|
||||||
// Опционально: написать отдельное сообщение в чат
|
// Опционально: написать отдельное сообщение в чат
|
||||||
await bot.SendMessage(command.ChatId, $"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, cancellationToken: ct);
|
await bot.SendMessage(
|
||||||
|
chatId: command.ChatId,
|
||||||
|
messageThreadId: command.MessageThreadId,
|
||||||
|
text: $"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.",
|
||||||
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
|
cancellationToken: ct);
|
||||||
|
|
||||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
|
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
|
||||||
if (mode.ShouldSendDirectMessages())
|
if (mode.ShouldSendDirectMessages())
|
||||||
|
|||||||
@@ -144,14 +144,31 @@ public sealed class CreateSessionHandler(
|
|||||||
transaction);
|
transaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
int? messageThreadId = null;
|
var topicDestination = TelegramTopicRouting.ResolveNewScheduleDestination(
|
||||||
if (message.Chat.IsForum)
|
message.Chat.IsForum,
|
||||||
|
message.MessageThreadId);
|
||||||
|
var messageThreadId = topicDestination.MessageThreadId;
|
||||||
|
var topicCreatedByBot = topicDestination.TopicCreatedByBot;
|
||||||
|
if (topicDestination.ShouldCreateForumTopic)
|
||||||
{
|
{
|
||||||
var topic = await botClient.CreateForumTopic(
|
try
|
||||||
chatId: chatId,
|
{
|
||||||
name: $"🎲 Игры: {title}",
|
var topic = await botClient.CreateForumTopic(
|
||||||
cancellationToken: cancellationToken);
|
chatId: chatId,
|
||||||
messageThreadId = topic.MessageThreadId;
|
name: $"🎲 Игры: {title}",
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
messageThreadId = topic.MessageThreadId;
|
||||||
|
}
|
||||||
|
catch (Telegram.Bot.Exceptions.ApiRequestException ex)
|
||||||
|
when (TelegramTopicRouting.IsMissingForumTopicRightsError(ex.Message))
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
await botClient.SendMessage(
|
||||||
|
chatId,
|
||||||
|
TelegramTopicRouting.MissingForumTopicRightsMessage,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var batchId = Guid.NewGuid();
|
var batchId = Guid.NewGuid();
|
||||||
@@ -161,8 +178,8 @@ public sealed class CreateSessionHandler(
|
|||||||
{
|
{
|
||||||
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
||||||
"""
|
"""
|
||||||
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, max_players)
|
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players)
|
||||||
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId, @MaxPlayers)
|
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers)
|
||||||
RETURNING id;
|
RETURNING id;
|
||||||
""",
|
""",
|
||||||
new
|
new
|
||||||
@@ -173,6 +190,7 @@ public sealed class CreateSessionHandler(
|
|||||||
Link = link,
|
Link = link,
|
||||||
ScheduledAt = scheduledAt,
|
ScheduledAt = scheduledAt,
|
||||||
ThreadId = messageThreadId,
|
ThreadId = messageThreadId,
|
||||||
|
TopicCreatedByBot = topicCreatedByBot,
|
||||||
MaxPlayers = parseResult.MaxPlayers,
|
MaxPlayers = parseResult.MaxPlayers,
|
||||||
Status = SessionStatus.Planned
|
Status = SessionStatus.Planned
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
||||||
@@ -12,7 +13,13 @@ public sealed record DeleteSessionCommand(
|
|||||||
long ChatId,
|
long ChatId,
|
||||||
int MessageId);
|
int MessageId);
|
||||||
|
|
||||||
internal sealed record DeleteSessionInfoDto(string Title, Guid BatchId, bool CanManage, int? ThreadId);
|
internal sealed record DeleteSessionInfoDto(
|
||||||
|
string Title,
|
||||||
|
Guid BatchId,
|
||||||
|
Guid GroupId,
|
||||||
|
bool CanManage,
|
||||||
|
int? ThreadId,
|
||||||
|
bool TopicCreatedByBot);
|
||||||
|
|
||||||
public sealed class DeleteSessionHandler(
|
public sealed class DeleteSessionHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
@@ -29,7 +36,9 @@ public sealed class DeleteSessionHandler(
|
|||||||
"""
|
"""
|
||||||
SELECT s.title AS Title,
|
SELECT s.title AS Title,
|
||||||
s.batch_id AS BatchId,
|
s.batch_id AS BatchId,
|
||||||
|
s.group_id AS GroupId,
|
||||||
s.thread_id AS ThreadId,
|
s.thread_id AS ThreadId,
|
||||||
|
s.topic_created_by_bot AS TopicCreatedByBot,
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM group_managers gm
|
FROM group_managers gm
|
||||||
@@ -57,15 +66,23 @@ public sealed class DeleteSessionHandler(
|
|||||||
// 2. Delete session
|
// 2. Delete session
|
||||||
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
|
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
|
||||||
|
|
||||||
// 3. Check if any sessions are left in the batch
|
var remainingInTopic = session.ThreadId.HasValue
|
||||||
var remainingInBatch = await connection.ExecuteScalarAsync<int>(
|
? await connection.ExecuteScalarAsync<int>(
|
||||||
"SELECT COUNT(*) FROM sessions WHERE batch_id = @BatchId",
|
"""
|
||||||
new { BatchId = session.BatchId }, transaction);
|
SELECT COUNT(*)
|
||||||
|
FROM sessions
|
||||||
|
WHERE group_id = @GroupId
|
||||||
|
AND thread_id = @ThreadId
|
||||||
|
""",
|
||||||
|
new { session.GroupId, ThreadId = session.ThreadId.Value },
|
||||||
|
transaction)
|
||||||
|
: 0;
|
||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
// 4. If no sessions left and we have a forum topic, delete the topic
|
// 4. If no sessions are left in a bot-owned forum topic, delete the topic.
|
||||||
if (remainingInBatch == 0 && session.ThreadId.HasValue)
|
if (session.ThreadId.HasValue &&
|
||||||
|
TelegramTopicRouting.ShouldDeleteForumTopic(session.TopicCreatedByBot, remainingInTopic))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
+5
-1
@@ -14,7 +14,7 @@ namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
|||||||
|
|
||||||
internal sealed record AwaitingProposalDto(
|
internal sealed record AwaitingProposalDto(
|
||||||
Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt,
|
Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt,
|
||||||
Guid BatchId, int? BatchMessageId, long TelegramChatId, string NotificationMode);
|
Guid BatchId, int? BatchMessageId, long TelegramChatId, int? ThreadId, string NotificationMode);
|
||||||
|
|
||||||
internal sealed record VoteParticipantDto(
|
internal sealed record VoteParticipantDto(
|
||||||
Guid PlayerId,
|
Guid PlayerId,
|
||||||
@@ -57,6 +57,7 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
|
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
|
||||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||||
g.telegram_chat_id AS TelegramChatId,
|
g.telegram_chat_id AS TelegramChatId,
|
||||||
|
s.thread_id AS ThreadId,
|
||||||
s.notification_mode AS NotificationMode
|
s.notification_mode AS NotificationMode
|
||||||
FROM reschedule_proposals rp
|
FROM reschedule_proposals rp
|
||||||
JOIN sessions s ON s.id = rp.session_id
|
JOIN sessions s ON s.id = rp.session_id
|
||||||
@@ -84,6 +85,7 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
{
|
{
|
||||||
await bot.SendMessage(
|
await bot.SendMessage(
|
||||||
chatId: chatId,
|
chatId: chatId,
|
||||||
|
messageThreadId: proposal.ThreadId,
|
||||||
text: $"⚠️ {parseError}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>",
|
text: $"⚠️ {parseError}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>",
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
@@ -161,6 +163,7 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
|
|
||||||
var voteMsg = await bot.SendMessage(
|
var voteMsg = await bot.SendMessage(
|
||||||
chatId: chatId,
|
chatId: chatId,
|
||||||
|
messageThreadId: proposal.ThreadId,
|
||||||
text: voteText,
|
text: voteText,
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
replyMarkup: keyboard,
|
replyMarkup: keyboard,
|
||||||
@@ -242,6 +245,7 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
|
|
||||||
await bot.SendMessage(
|
await bot.SendMessage(
|
||||||
chatId: chatId,
|
chatId: chatId,
|
||||||
|
messageThreadId: proposal.ThreadId,
|
||||||
text: $"✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)\n\n<i>Участников нет — голосование не требуется.</i>",
|
text: $"✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)\n\n<i>Участников нет — голосование не требуется.</i>",
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public sealed record InitiateRescheduleCommand(
|
|||||||
long TelegramUserId,
|
long TelegramUserId,
|
||||||
string CallbackQueryId,
|
string CallbackQueryId,
|
||||||
long ChatId,
|
long ChatId,
|
||||||
|
int? MessageThreadId,
|
||||||
int MessageId);
|
int MessageId);
|
||||||
|
|
||||||
// ── DTOs ─────────────────────────────────────────────────────────────
|
// ── DTOs ─────────────────────────────────────────────────────────────
|
||||||
@@ -96,6 +97,7 @@ public sealed class InitiateRescheduleHandler(
|
|||||||
|
|
||||||
await bot.SendMessage(
|
await bot.SendMessage(
|
||||||
chatId: command.ChatId,
|
chatId: command.ChatId,
|
||||||
|
messageThreadId: command.MessageThreadId,
|
||||||
text: $"""
|
text: $"""
|
||||||
⏰ Укажите 2-3 варианта времени для сессии «{session.Title}» и дедлайн голосования.
|
⏰ Укажите 2-3 варианта времени для сессии «{session.Title}» и дедлайн голосования.
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ internal sealed record DueRescheduleProposalDto(
|
|||||||
int? BatchMessageId,
|
int? BatchMessageId,
|
||||||
int? VoteMessageId,
|
int? VoteMessageId,
|
||||||
long TelegramChatId,
|
long TelegramChatId,
|
||||||
|
int? ThreadId,
|
||||||
string NotificationMode);
|
string NotificationMode);
|
||||||
|
|
||||||
public sealed class RescheduleVotingDeadlineService(
|
public sealed class RescheduleVotingDeadlineService(
|
||||||
@@ -93,6 +94,7 @@ public sealed class RescheduleVotingDeadlineService(
|
|||||||
s.batch_id AS BatchId,
|
s.batch_id AS BatchId,
|
||||||
s.batch_message_id AS BatchMessageId,
|
s.batch_message_id AS BatchMessageId,
|
||||||
s.notification_mode AS NotificationMode,
|
s.notification_mode AS NotificationMode,
|
||||||
|
s.thread_id AS ThreadId,
|
||||||
g.telegram_chat_id AS TelegramChatId
|
g.telegram_chat_id AS TelegramChatId
|
||||||
FROM reschedule_proposals rp
|
FROM reschedule_proposals rp
|
||||||
JOIN sessions s ON s.id = rp.session_id
|
JOIN sessions s ON s.id = rp.session_id
|
||||||
@@ -324,6 +326,7 @@ public sealed class RescheduleVotingDeadlineService(
|
|||||||
{
|
{
|
||||||
await bot.SendMessage(
|
await bot.SendMessage(
|
||||||
chatId: proposal.TelegramChatId,
|
chatId: proposal.TelegramChatId,
|
||||||
|
messageThreadId: proposal.ThreadId,
|
||||||
text: $"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}».",
|
text: $"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}».",
|
||||||
parseMode: ParseMode.Html,
|
parseMode: ParseMode.Html,
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
|
public sealed record TelegramTopicDestination(
|
||||||
|
int? MessageThreadId,
|
||||||
|
bool ShouldCreateForumTopic,
|
||||||
|
bool TopicCreatedByBot);
|
||||||
|
|
||||||
|
public static class TelegramTopicRouting
|
||||||
|
{
|
||||||
|
public const string MissingForumTopicRightsMessage =
|
||||||
|
"Не удалось создать Telegram topic. Сделайте бота admin и включите право Manage Topics, затем повторите команду.";
|
||||||
|
|
||||||
|
public static TelegramTopicDestination ResolveNewScheduleDestination(
|
||||||
|
bool chatIsForum,
|
||||||
|
int? incomingMessageThreadId)
|
||||||
|
{
|
||||||
|
if (!chatIsForum)
|
||||||
|
{
|
||||||
|
return new TelegramTopicDestination(null, ShouldCreateForumTopic: false, TopicCreatedByBot: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incomingMessageThreadId.HasValue)
|
||||||
|
{
|
||||||
|
return new TelegramTopicDestination(
|
||||||
|
incomingMessageThreadId,
|
||||||
|
ShouldCreateForumTopic: false,
|
||||||
|
TopicCreatedByBot: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TelegramTopicDestination(null, ShouldCreateForumTopic: true, TopicCreatedByBot: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool ShouldDeleteForumTopic(bool topicCreatedByBot, int remainingSessionsInTopic) =>
|
||||||
|
topicCreatedByBot && remainingSessionsInTopic == 0;
|
||||||
|
|
||||||
|
public static bool IsMissingForumTopicRightsError(string apiError) =>
|
||||||
|
apiError.Contains("not enough rights", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
apiError.Contains("CHAT_ADMIN_REQUIRED", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
apiError.Contains("not an administrator", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
@@ -105,6 +105,7 @@ public sealed class UpdateRouter(
|
|||||||
TelegramUserId: query.From.Id,
|
TelegramUserId: query.From.Id,
|
||||||
CallbackQueryId: query.Id,
|
CallbackQueryId: query.Id,
|
||||||
ChatId: message.Chat.Id,
|
ChatId: message.Chat.Id,
|
||||||
|
MessageThreadId: message.MessageThreadId,
|
||||||
MessageId: message.MessageId);
|
MessageId: message.MessageId);
|
||||||
|
|
||||||
await cancelSessionHandler.HandleAsync(command, ct);
|
await cancelSessionHandler.HandleAsync(command, ct);
|
||||||
@@ -144,6 +145,7 @@ public sealed class UpdateRouter(
|
|||||||
TelegramUserId: query.From.Id,
|
TelegramUserId: query.From.Id,
|
||||||
CallbackQueryId: query.Id,
|
CallbackQueryId: query.Id,
|
||||||
ChatId: message.Chat.Id,
|
ChatId: message.Chat.Id,
|
||||||
|
MessageThreadId: message.MessageThreadId,
|
||||||
MessageId: message.MessageId);
|
MessageId: message.MessageId);
|
||||||
|
|
||||||
await initiateRescheduleHandler.HandleAsync(command, ct);
|
await initiateRescheduleHandler.HandleAsync(command, ct);
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE sessions
|
||||||
|
ADD COLUMN topic_created_by_bot BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
UPDATE sessions
|
||||||
|
SET topic_created_by_bot = TRUE
|
||||||
|
WHERE thread_id IS NOT NULL;
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="nav-version">v1.13.0</div>
|
<div class="nav-version">v1.14.0</div>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
<NotAuthorized>
|
<NotAuthorized>
|
||||||
@@ -79,4 +79,4 @@
|
|||||||
|
|
||||||
private void ToggleMenu() => isOpen = !isOpen;
|
private void ToggleMenu() => isOpen = !isOpen;
|
||||||
private void CloseMenu() => isOpen = false;
|
private void CloseMenu() => isOpen = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ public sealed record WebSession(
|
|||||||
int? MaxPlayers,
|
int? MaxPlayers,
|
||||||
int ActivePlayerCount,
|
int ActivePlayerCount,
|
||||||
int WaitlistedPlayerCount,
|
int WaitlistedPlayerCount,
|
||||||
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue);
|
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue,
|
||||||
|
int? ThreadId = null);
|
||||||
|
|
||||||
public sealed record WebParticipant(
|
public sealed record WebParticipant(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -73,7 +74,8 @@ internal sealed record WebBatchSessionRow(
|
|||||||
int? BatchMessageId,
|
int? BatchMessageId,
|
||||||
long TelegramChatId,
|
long TelegramChatId,
|
||||||
int? ThreadId,
|
int? ThreadId,
|
||||||
string NotificationMode);
|
string NotificationMode,
|
||||||
|
bool TopicCreatedByBot = false);
|
||||||
internal sealed record WebTemplateGroupDto(long TelegramChatId);
|
internal sealed record WebTemplateGroupDto(long TelegramChatId);
|
||||||
|
|
||||||
public sealed class SessionService(
|
public sealed class SessionService(
|
||||||
@@ -314,7 +316,8 @@ public sealed class SessionService(
|
|||||||
s.max_players AS MaxPlayers,
|
s.max_players AS MaxPlayers,
|
||||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||||
s.notification_mode AS NotificationMode
|
s.notification_mode AS NotificationMode,
|
||||||
|
s.thread_id AS ThreadId
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
@@ -351,7 +354,8 @@ public sealed class SessionService(
|
|||||||
s.max_players AS MaxPlayers,
|
s.max_players AS MaxPlayers,
|
||||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||||
s.notification_mode AS NotificationMode
|
s.notification_mode AS NotificationMode,
|
||||||
|
s.thread_id AS ThreadId
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
@@ -409,7 +413,8 @@ public sealed class SessionService(
|
|||||||
s.max_players AS MaxPlayers,
|
s.max_players AS MaxPlayers,
|
||||||
0 AS ActivePlayerCount,
|
0 AS ActivePlayerCount,
|
||||||
0 AS WaitlistedPlayerCount,
|
0 AS WaitlistedPlayerCount,
|
||||||
s.notification_mode AS NotificationMode
|
s.notification_mode AS NotificationMode,
|
||||||
|
s.thread_id AS ThreadId
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE s.id = @Id AND s.group_id = @GroupId",
|
WHERE s.id = @Id AND s.group_id = @GroupId",
|
||||||
@@ -463,7 +468,11 @@ public sealed class SessionService(
|
|||||||
"\n" +
|
"\n" +
|
||||||
$"👥 Мест: <b>{(maxPlayers.HasValue ? maxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "без лимита")}</b>";
|
$"👥 Мест: <b>{(maxPlayers.HasValue ? maxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "без лимита")}</b>";
|
||||||
|
|
||||||
await bot.SendMessage(oldSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
await bot.SendMessage(
|
||||||
|
chatId: oldSession.TelegramChatId,
|
||||||
|
messageThreadId: oldSession.ThreadId,
|
||||||
|
text: notification,
|
||||||
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||||||
|
|
||||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(oldSession.NotificationMode);
|
var mode = SessionNotificationModeExtensions.FromDatabaseValue(oldSession.NotificationMode);
|
||||||
if (mode.ShouldSendDirectMessages())
|
if (mode.ShouldSendDirectMessages())
|
||||||
@@ -490,7 +499,8 @@ public sealed class SessionService(
|
|||||||
s.max_players AS MaxPlayers,
|
s.max_players AS MaxPlayers,
|
||||||
0 AS ActivePlayerCount,
|
0 AS ActivePlayerCount,
|
||||||
0 AS WaitlistedPlayerCount,
|
0 AS WaitlistedPlayerCount,
|
||||||
s.notification_mode AS NotificationMode
|
s.notification_mode AS NotificationMode,
|
||||||
|
s.thread_id AS ThreadId
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||||||
@@ -567,8 +577,9 @@ public sealed class SessionService(
|
|||||||
await transaction.CommitAsync();
|
await transaction.CommitAsync();
|
||||||
|
|
||||||
await bot.SendMessage(
|
await bot.SendMessage(
|
||||||
session.TelegramChatId,
|
chatId: session.TelegramChatId,
|
||||||
$"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
|
messageThreadId: session.ThreadId,
|
||||||
|
text: $"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||||||
|
|
||||||
if (session.BatchMessageId.HasValue)
|
if (session.BatchMessageId.HasValue)
|
||||||
@@ -612,7 +623,8 @@ public sealed class SessionService(
|
|||||||
s.max_players AS MaxPlayers,
|
s.max_players AS MaxPlayers,
|
||||||
0 AS ActivePlayerCount,
|
0 AS ActivePlayerCount,
|
||||||
0 AS WaitlistedPlayerCount,
|
0 AS WaitlistedPlayerCount,
|
||||||
s.notification_mode AS NotificationMode
|
s.notification_mode AS NotificationMode,
|
||||||
|
s.thread_id AS ThreadId
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||||||
@@ -697,15 +709,17 @@ public sealed class SessionService(
|
|||||||
await transaction.CommitAsync();
|
await transaction.CommitAsync();
|
||||||
|
|
||||||
await bot.SendMessage(
|
await bot.SendMessage(
|
||||||
session.TelegramChatId,
|
chatId: session.TelegramChatId,
|
||||||
$"🚪 <b>{System.Net.WebUtility.HtmlEncode(participant.DisplayName)}</b> удален(а) из сессии «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
|
messageThreadId: session.ThreadId,
|
||||||
|
text: $"🚪 <b>{System.Net.WebUtility.HtmlEncode(participant.DisplayName)}</b> удален(а) из сессии «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||||||
|
|
||||||
if (promoted is not null)
|
if (promoted is not null)
|
||||||
{
|
{
|
||||||
await bot.SendMessage(
|
await bot.SendMessage(
|
||||||
session.TelegramChatId,
|
chatId: session.TelegramChatId,
|
||||||
$"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
|
messageThreadId: session.ThreadId,
|
||||||
|
text: $"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||||||
|
|
||||||
if (session.BatchMessageId.HasValue)
|
if (session.BatchMessageId.HasValue)
|
||||||
@@ -813,6 +827,7 @@ public sealed class SessionService(
|
|||||||
s.batch_message_id AS BatchMessageId,
|
s.batch_message_id AS BatchMessageId,
|
||||||
g.telegram_chat_id AS TelegramChatId,
|
g.telegram_chat_id AS TelegramChatId,
|
||||||
s.thread_id AS ThreadId,
|
s.thread_id AS ThreadId,
|
||||||
|
s.topic_created_by_bot AS TopicCreatedByBot,
|
||||||
s.notification_mode AS NotificationMode
|
s.notification_mode AS NotificationMode
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
@@ -865,7 +880,11 @@ public sealed class SessionService(
|
|||||||
$"🗓 Новое начало: <b>{firstScheduledAt.FormatMoscow()}</b> (МСК)\n" +
|
$"🗓 Новое начало: <b>{firstScheduledAt.FormatMoscow()}</b> (МСК)\n" +
|
||||||
$"↔️ Шаг: <b>{intervalDays} дн.</b>";
|
$"↔️ Шаг: <b>{intervalDays} дн.</b>";
|
||||||
|
|
||||||
await bot.SendMessage(firstSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
await bot.SendMessage(
|
||||||
|
chatId: firstSession.TelegramChatId,
|
||||||
|
messageThreadId: firstSession.ThreadId,
|
||||||
|
text: notification,
|
||||||
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||||||
|
|
||||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(firstSession.NotificationMode);
|
var mode = SessionNotificationModeExtensions.FromDatabaseValue(firstSession.NotificationMode);
|
||||||
if (mode.ShouldSendDirectMessages())
|
if (mode.ShouldSendDirectMessages())
|
||||||
@@ -892,6 +911,7 @@ public sealed class SessionService(
|
|||||||
s.batch_message_id AS BatchMessageId,
|
s.batch_message_id AS BatchMessageId,
|
||||||
g.telegram_chat_id AS TelegramChatId,
|
g.telegram_chat_id AS TelegramChatId,
|
||||||
s.thread_id AS ThreadId,
|
s.thread_id AS ThreadId,
|
||||||
|
s.topic_created_by_bot AS TopicCreatedByBot,
|
||||||
s.notification_mode AS NotificationMode
|
s.notification_mode AS NotificationMode
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
@@ -920,8 +940,8 @@ public sealed class SessionService(
|
|||||||
var scheduledAt = BatchSchedulePlanner.ShiftForClone(sourceSession.ScheduledAt, interval);
|
var scheduledAt = BatchSchedulePlanner.ShiftForClone(sourceSession.ScheduledAt, interval);
|
||||||
var sessionId = await conn.ExecuteScalarAsync<Guid>(
|
var sessionId = await conn.ExecuteScalarAsync<Guid>(
|
||||||
"""
|
"""
|
||||||
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, max_players, notification_mode)
|
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players, notification_mode)
|
||||||
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @MaxPlayers, @NotificationMode)
|
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers, @NotificationMode)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
new
|
new
|
||||||
@@ -933,6 +953,7 @@ public sealed class SessionService(
|
|||||||
ScheduledAt = scheduledAt,
|
ScheduledAt = scheduledAt,
|
||||||
Status = SessionStatus.Planned,
|
Status = SessionStatus.Planned,
|
||||||
ThreadId = threadId,
|
ThreadId = threadId,
|
||||||
|
sourceSession.TopicCreatedByBot,
|
||||||
sourceSession.MaxPlayers,
|
sourceSession.MaxPlayers,
|
||||||
sourceSession.NotificationMode
|
sourceSession.NotificationMode
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
namespace GmRelay.Bot.Tests.Infrastructure.Telegram;
|
||||||
|
|
||||||
|
public sealed class TelegramTopicIntegrationSmokeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task BotAndWebCode_ShouldPersistAndUseTopicOwnership()
|
||||||
|
{
|
||||||
|
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V015__add_topic_ownership.sql");
|
||||||
|
var createHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs");
|
||||||
|
var deleteHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs");
|
||||||
|
|
||||||
|
Assert.Contains("topic_created_by_bot", migration, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("ResolveNewScheduleDestination", createHandler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("message.MessageThreadId", createHandler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("topic_created_by_bot", createHandler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("MissingForumTopicRightsMessage", createHandler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("TopicCreatedByBot", deleteHandler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("ShouldDeleteForumTopic", deleteHandler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("remainingInTopic", deleteHandler, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GroupNotifications_ShouldSendToStoredForumTopic()
|
||||||
|
{
|
||||||
|
var confirmationHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs");
|
||||||
|
var joinLinkHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs");
|
||||||
|
var rsvpHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs");
|
||||||
|
var cancelHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs");
|
||||||
|
var initiateRescheduleHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs");
|
||||||
|
var rescheduleInputHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs");
|
||||||
|
var rescheduleDeadlineService = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs");
|
||||||
|
|
||||||
|
Assert.Contains("int? ThreadId", confirmationHandler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("s.thread_id AS ThreadId", confirmationHandler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("messageThreadId: session.ThreadId", confirmationHandler, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
Assert.Contains("int? ThreadId", joinLinkHandler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("s.thread_id AS ThreadId", joinLinkHandler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("messageThreadId: session.ThreadId", joinLinkHandler, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
Assert.Contains("int? ThreadId", rsvpHandler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("s.thread_id AS ThreadId", rsvpHandler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("messageThreadId: session.ThreadId", rsvpHandler, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
Assert.Contains("int? MessageThreadId", cancelHandler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("messageThreadId: command.MessageThreadId", cancelHandler, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
Assert.Contains("int? MessageThreadId", initiateRescheduleHandler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("messageThreadId: command.MessageThreadId", initiateRescheduleHandler, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
Assert.Contains("int? ThreadId", rescheduleInputHandler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("s.thread_id AS ThreadId", rescheduleInputHandler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("messageThreadId: proposal.ThreadId", rescheduleInputHandler, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
Assert.Contains("int? ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("s.thread_id AS ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("messageThreadId: proposal.ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||||
|
{
|
||||||
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||||
|
if (File.Exists(candidate))
|
||||||
|
{
|
||||||
|
return await File.ReadAllTextAsync(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Infrastructure.Telegram;
|
||||||
|
|
||||||
|
public sealed class TelegramTopicRoutingTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ResolveNewScheduleDestination_UsesIncomingTopic_WhenForumCommandWasSentInsideTopic()
|
||||||
|
{
|
||||||
|
var destination = TelegramTopicRouting.ResolveNewScheduleDestination(
|
||||||
|
chatIsForum: true,
|
||||||
|
incomingMessageThreadId: 42);
|
||||||
|
|
||||||
|
Assert.Equal(42, destination.MessageThreadId);
|
||||||
|
Assert.False(destination.ShouldCreateForumTopic);
|
||||||
|
Assert.False(destination.TopicCreatedByBot);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveNewScheduleDestination_CreatesBotOwnedTopic_WhenForumCommandWasSentInRoot()
|
||||||
|
{
|
||||||
|
var destination = TelegramTopicRouting.ResolveNewScheduleDestination(
|
||||||
|
chatIsForum: true,
|
||||||
|
incomingMessageThreadId: null);
|
||||||
|
|
||||||
|
Assert.Null(destination.MessageThreadId);
|
||||||
|
Assert.True(destination.ShouldCreateForumTopic);
|
||||||
|
Assert.True(destination.TopicCreatedByBot);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveNewScheduleDestination_UsesPlainChat_WhenChatIsNotForum()
|
||||||
|
{
|
||||||
|
var destination = TelegramTopicRouting.ResolveNewScheduleDestination(
|
||||||
|
chatIsForum: false,
|
||||||
|
incomingMessageThreadId: 42);
|
||||||
|
|
||||||
|
Assert.Null(destination.MessageThreadId);
|
||||||
|
Assert.False(destination.ShouldCreateForumTopic);
|
||||||
|
Assert.False(destination.TopicCreatedByBot);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingForumTopicRightsMessage_NamesRequiredAdminRight()
|
||||||
|
{
|
||||||
|
Assert.Contains("admin", TelegramTopicRouting.MissingForumTopicRightsMessage, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("Manage Topics", TelegramTopicRouting.MissingForumTopicRightsMessage, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Bad Request: not enough rights to create forum topic")]
|
||||||
|
[InlineData("Bad Request: CHAT_ADMIN_REQUIRED")]
|
||||||
|
[InlineData("Forbidden: bot is not an administrator")]
|
||||||
|
public void IsMissingForumTopicRightsError_MatchesAdminPermissionErrors(string apiError)
|
||||||
|
{
|
||||||
|
Assert.True(TelegramTopicRouting.IsMissingForumTopicRightsError(apiError));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsMissingForumTopicRightsError_IgnoresUnrelatedApiErrors()
|
||||||
|
{
|
||||||
|
Assert.False(TelegramTopicRouting.IsMissingForumTopicRightsError("Bad Request: topic name is invalid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true, 0, true)]
|
||||||
|
[InlineData(true, 1, false)]
|
||||||
|
[InlineData(false, 0, false)]
|
||||||
|
public void ShouldDeleteForumTopic_DeletesOnlyBotOwnedEmptyTopic(
|
||||||
|
bool topicCreatedByBot,
|
||||||
|
int remainingSessionsInTopic,
|
||||||
|
bool expected)
|
||||||
|
{
|
||||||
|
var shouldDelete = TelegramTopicRouting.ShouldDeleteForumTopic(
|
||||||
|
topicCreatedByBot,
|
||||||
|
remainingSessionsInTopic);
|
||||||
|
|
||||||
|
Assert.Equal(expected, shouldDelete);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user