Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a391c51761 | |||
| e15652399b | |||
| 40b13db320 | |||
| e0ee8fc962 | |||
| 6707a2850c |
@@ -23,11 +23,18 @@ internal static class SessionListMessageRenderer
|
|||||||
|
|
||||||
public static IReadOnlyList<PlatformMessageAction> RenderActions(IReadOnlyList<SessionListItemDto> sessions)
|
public static IReadOnlyList<PlatformMessageAction> RenderActions(IReadOnlyList<SessionListItemDto> sessions)
|
||||||
{
|
{
|
||||||
if (sessions.Count == 0 || !sessions.First().CanManage)
|
if (sessions.Count == 0)
|
||||||
{
|
{
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return sessions.First().CanManage
|
||||||
|
? RenderManagerActions(sessions)
|
||||||
|
: RenderPlayerActions(sessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<PlatformMessageAction> RenderManagerActions(IReadOnlyList<SessionListItemDto> sessions)
|
||||||
|
{
|
||||||
var actions = new List<PlatformMessageAction>();
|
var actions = new List<PlatformMessageAction>();
|
||||||
|
|
||||||
foreach (var session in sessions)
|
foreach (var session in sessions)
|
||||||
@@ -36,19 +43,19 @@ internal static class SessionListMessageRenderer
|
|||||||
|
|
||||||
actions.Add(new PlatformMessageAction(
|
actions.Add(new PlatformMessageAction(
|
||||||
$"cancel_session:{session.Id}",
|
$"cancel_session:{session.Id}",
|
||||||
$"❌ {dateTitle}",
|
$"❌ Отменить {dateTitle}",
|
||||||
$"cancel_session:{session.Id}"));
|
$"cancel_session:{session.Id}"));
|
||||||
|
|
||||||
actions.Add(new PlatformMessageAction(
|
actions.Add(new PlatformMessageAction(
|
||||||
$"reschedule_session:{session.Id}",
|
$"reschedule_session:{session.Id}",
|
||||||
$"⏰ {dateTitle}",
|
$"⏰ Перенести {dateTitle}",
|
||||||
$"reschedule_session:{session.Id}"));
|
$"reschedule_session:{session.Id}"));
|
||||||
|
|
||||||
if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount))
|
if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount))
|
||||||
{
|
{
|
||||||
actions.Add(new PlatformMessageAction(
|
actions.Add(new PlatformMessageAction(
|
||||||
$"promote_waitlist:{session.Id}",
|
$"promote_waitlist:{session.Id}",
|
||||||
$"⬆️ Из ожидания {dateTitle}",
|
$"⬆️ С ожидания {dateTitle}",
|
||||||
$"promote_waitlist:{session.Id}"));
|
$"promote_waitlist:{session.Id}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,4 +67,31 @@ internal static class SessionListMessageRenderer
|
|||||||
|
|
||||||
return actions;
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<PlatformMessageAction> RenderPlayerActions(IReadOnlyList<SessionListItemDto> sessions)
|
||||||
|
{
|
||||||
|
var actions = new List<PlatformMessageAction>();
|
||||||
|
|
||||||
|
foreach (var session in sessions)
|
||||||
|
{
|
||||||
|
var dateTitle = session.ScheduledAt.FormatMoscowShort();
|
||||||
|
|
||||||
|
if (session.IsUserActive || session.IsUserWaitlisted)
|
||||||
|
{
|
||||||
|
actions.Add(new PlatformMessageAction(
|
||||||
|
$"leave_session:{session.Id}",
|
||||||
|
session.IsUserWaitlisted ? $"✖️ Выйти из ожидания {dateTitle}" : $"✖️ Выйти {dateTitle}",
|
||||||
|
$"leave_session:{session.Id}"));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
actions.Add(new PlatformMessageAction(
|
||||||
|
$"join_session:{session.Id}",
|
||||||
|
$"✅ Записаться {dateTitle}",
|
||||||
|
$"join_session:{session.Id}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using Telegram.Bot;
|
||||||
|
using Telegram.Bot.Types;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the bot's command list with Telegram so users see the
|
||||||
|
/// command menu when they type "/" in a chat.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TelegramCommandsSetupService(
|
||||||
|
ITelegramBotClient bot,
|
||||||
|
ILogger<TelegramCommandsSetupService> logger) : IHostedService
|
||||||
|
{
|
||||||
|
public async Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var commands = new[]
|
||||||
|
{
|
||||||
|
new BotCommand { Command = "start", Description = "Начать работу с ботом" },
|
||||||
|
new BotCommand { Command = "newsession", Description = "Создать новую игровую сессию" },
|
||||||
|
new BotCommand { Command = "listsessions", Description = "Список предстоящих сессий" },
|
||||||
|
new BotCommand { Command = "exportcalendar", Description = "Экспортировать расписание в ICS" },
|
||||||
|
new BotCommand { Command = "help", Description = "Справка по командам" }
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await bot.SetMyCommands(
|
||||||
|
commands,
|
||||||
|
scope: new BotCommandScopeAllPrivateChats(),
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
await bot.SetMyCommands(
|
||||||
|
commands,
|
||||||
|
scope: new BotCommandScopeAllGroupChats(),
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Telegram command menu registered for private chats and groups.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to register Telegram command menu.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
}
|
||||||
@@ -366,6 +366,13 @@ public sealed class UpdateRouter(
|
|||||||
text: """
|
text: """
|
||||||
GM-Relay — бот для управления игровыми сессиями.
|
GM-Relay — бот для управления игровыми сессиями.
|
||||||
|
|
||||||
|
/start — начать работу с ботом
|
||||||
|
/newsession — создать новую игровую сессию
|
||||||
|
/listsessions — список предстоящих сессий
|
||||||
|
/exportcalendar — экспортировать расписание в ICS
|
||||||
|
/help — эта справка
|
||||||
|
|
||||||
|
Пример создания сессии:
|
||||||
/newsession
|
/newsession
|
||||||
Название: My Game
|
Название: My Game
|
||||||
Время: 15.05.2026 19:30
|
Время: 15.05.2026 19:30
|
||||||
@@ -377,10 +384,8 @@ public sealed class UpdateRouter(
|
|||||||
Игр: 4
|
Игр: 4
|
||||||
Интервал: 7
|
Интервал: 7
|
||||||
|
|
||||||
/listsessions — список предстоящих сессий
|
|
||||||
Для owner/co-GM /listsessions показывает кнопки отмены, переноса, удаления и повышения из листа ожидания.
|
Для owner/co-GM /listsessions показывает кнопки отмены, переноса, удаления и повышения из листа ожидания.
|
||||||
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
|
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
|
||||||
/help — эта справка
|
|
||||||
""",
|
""",
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ builder.Services.AddSingleton<DirectSessionNotificationSender>();
|
|||||||
// ── Telegram infrastructure ──────────────────────────────────────────
|
// ── Telegram infrastructure ──────────────────────────────────────────
|
||||||
builder.Services.AddSingleton<UpdateRouter>();
|
builder.Services.AddSingleton<UpdateRouter>();
|
||||||
builder.Services.AddSingleton<ITelegramUpdateHandler>(sp => sp.GetRequiredService<UpdateRouter>());
|
builder.Services.AddSingleton<ITelegramUpdateHandler>(sp => sp.GetRequiredService<UpdateRouter>());
|
||||||
|
builder.Services.AddHostedService<TelegramCommandsSetupService>();
|
||||||
builder.Services.AddHostedService<TelegramMiniAppMenuButtonService>();
|
builder.Services.AddHostedService<TelegramMiniAppMenuButtonService>();
|
||||||
builder.Services.AddHostedService<TelegramBotService>();
|
builder.Services.AddHostedService<TelegramBotService>();
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,17 @@ using Npgsql;
|
|||||||
|
|
||||||
namespace GmRelay.Shared.Features.Sessions.ListSessions;
|
namespace GmRelay.Shared.Features.Sessions.ListSessions;
|
||||||
|
|
||||||
public sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
|
public sealed record SessionListItemDto(
|
||||||
|
Guid Id,
|
||||||
|
string Title,
|
||||||
|
DateTime ScheduledAt,
|
||||||
|
string Status,
|
||||||
|
int? MaxPlayers,
|
||||||
|
int PlayerCount,
|
||||||
|
int WaitlistCount,
|
||||||
|
bool CanManage,
|
||||||
|
bool IsUserActive,
|
||||||
|
bool IsUserWaitlisted);
|
||||||
|
|
||||||
public sealed record SessionListResult(
|
public sealed record SessionListResult(
|
||||||
IReadOnlyList<SessionListItemDto> Sessions,
|
IReadOnlyList<SessionListItemDto> Sessions,
|
||||||
@@ -29,7 +39,27 @@ public sealed class ListSessionsHandler(
|
|||||||
WHERE gm.group_id = s.group_id
|
WHERE gm.group_id = s.group_id
|
||||||
AND manager_player.platform = @Platform
|
AND manager_player.platform = @Platform
|
||||||
AND manager_player.external_user_id = @ExternalUserId
|
AND manager_player.external_user_id = @ExternalUserId
|
||||||
) AS CanManage
|
) AS CanManage,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM session_participants user_sp
|
||||||
|
JOIN players user_p ON user_p.id = user_sp.player_id
|
||||||
|
WHERE user_sp.session_id = s.id
|
||||||
|
AND user_sp.is_gm = false
|
||||||
|
AND user_sp.registration_status = @Active
|
||||||
|
AND user_p.platform = @Platform
|
||||||
|
AND user_p.external_user_id = @ExternalUserId
|
||||||
|
) AS IsUserActive,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM session_participants user_sp
|
||||||
|
JOIN players user_p ON user_p.id = user_sp.player_id
|
||||||
|
WHERE user_sp.session_id = s.id
|
||||||
|
AND user_sp.is_gm = false
|
||||||
|
AND user_sp.registration_status = @Waitlisted
|
||||||
|
AND user_p.platform = @Platform
|
||||||
|
AND user_p.external_user_id = @ExternalUserId
|
||||||
|
) AS IsUserWaitlisted
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON s.group_id = g.id
|
JOIN game_groups g ON s.group_id = g.id
|
||||||
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
||||||
|
|||||||
+74
-5
@@ -20,7 +20,9 @@ public sealed class SessionListMessageRendererTests
|
|||||||
4,
|
4,
|
||||||
3,
|
3,
|
||||||
1,
|
1,
|
||||||
true)
|
true,
|
||||||
|
false,
|
||||||
|
false)
|
||||||
};
|
};
|
||||||
|
|
||||||
var text = SessionListMessageRenderer.RenderText(sessions);
|
var text = SessionListMessageRenderer.RenderText(sessions);
|
||||||
@@ -32,25 +34,92 @@ public sealed class SessionListMessageRendererTests
|
|||||||
Assert.Contains(actions, a => a.Payload == $"reschedule_session:{sessionId}");
|
Assert.Contains(actions, a => a.Payload == $"reschedule_session:{sessionId}");
|
||||||
Assert.Contains(actions, a => a.Payload == $"promote_waitlist:{sessionId}");
|
Assert.Contains(actions, a => a.Payload == $"promote_waitlist:{sessionId}");
|
||||||
Assert.Contains(actions, a => a.Payload == $"delete_session:{sessionId}");
|
Assert.Contains(actions, a => a.Payload == $"delete_session:{sessionId}");
|
||||||
|
|
||||||
|
var shortDate = new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc).FormatMoscowShort();
|
||||||
|
Assert.Contains(actions, a => a.Label == $"❌ Отменить {shortDate}");
|
||||||
|
Assert.Contains(actions, a => a.Label == $"⏰ Перенести {shortDate}");
|
||||||
|
Assert.Contains(actions, a => a.Label == $"⬆️ С ожидания {shortDate}");
|
||||||
|
Assert.Contains(actions, a => a.Label == $"🗑 Удалить {shortDate}");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Render_ShouldHideManagerActions_WhenUserCannotManage()
|
public void Render_ShouldIncludeJoinAction_WhenPlayerIsNotRegistered()
|
||||||
{
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
var sessions = new[]
|
var sessions = new[]
|
||||||
{
|
{
|
||||||
new SessionListItemDto(
|
new SessionListItemDto(
|
||||||
Guid.NewGuid(),
|
sessionId,
|
||||||
|
"Ravenloft",
|
||||||
|
new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc),
|
||||||
|
SessionStatus.Planned,
|
||||||
|
4,
|
||||||
|
3,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false)
|
||||||
|
};
|
||||||
|
|
||||||
|
var actions = SessionListMessageRenderer.RenderActions(sessions);
|
||||||
|
var shortDate = new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc).FormatMoscowShort();
|
||||||
|
|
||||||
|
Assert.Single(actions);
|
||||||
|
Assert.Contains(actions, a => a.Payload == $"join_session:{sessionId}");
|
||||||
|
Assert.Contains(actions, a => a.Label == $"✅ Записаться {shortDate}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_ShouldIncludeLeaveAction_WhenPlayerIsActive()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var sessions = new[]
|
||||||
|
{
|
||||||
|
new SessionListItemDto(
|
||||||
|
sessionId,
|
||||||
|
"Ravenloft",
|
||||||
|
new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc),
|
||||||
|
SessionStatus.Planned,
|
||||||
|
4,
|
||||||
|
3,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false)
|
||||||
|
};
|
||||||
|
|
||||||
|
var actions = SessionListMessageRenderer.RenderActions(sessions);
|
||||||
|
var shortDate = new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc).FormatMoscowShort();
|
||||||
|
|
||||||
|
Assert.Single(actions);
|
||||||
|
Assert.Contains(actions, a => a.Payload == $"leave_session:{sessionId}");
|
||||||
|
Assert.Contains(actions, a => a.Label == $"✖️ Выйти {shortDate}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_ShouldIncludeLeaveWaitlistAction_WhenPlayerIsWaitlisted()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var sessions = new[]
|
||||||
|
{
|
||||||
|
new SessionListItemDto(
|
||||||
|
sessionId,
|
||||||
"Ravenloft",
|
"Ravenloft",
|
||||||
new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc),
|
new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc),
|
||||||
SessionStatus.Planned,
|
SessionStatus.Planned,
|
||||||
4,
|
4,
|
||||||
3,
|
3,
|
||||||
1,
|
1,
|
||||||
false)
|
false,
|
||||||
|
false,
|
||||||
|
true)
|
||||||
};
|
};
|
||||||
|
|
||||||
var actions = SessionListMessageRenderer.RenderActions(sessions);
|
var actions = SessionListMessageRenderer.RenderActions(sessions);
|
||||||
Assert.Empty(actions);
|
var shortDate = new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc).FormatMoscowShort();
|
||||||
|
|
||||||
|
Assert.Single(actions);
|
||||||
|
Assert.Contains(actions, a => a.Payload == $"leave_session:{sessionId}");
|
||||||
|
Assert.Contains(actions, a => a.Label == $"✖️ Выйти из ожидания {shortDate}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user