Compare commits

..

3 Commits

Author SHA1 Message Date
root fdb3445bec docs: bump README to v1.9.7, document player list kick 2026-05-04 17:15:06 +00:00
root c1f5d96e25 feat: show participant list, kick player, auto-promote waitlist 2026-05-04 17:11:23 +00:00
Toutsu c874f7b797 fix: combine session image and text into single Telegram message
Deploy Telegram Bot / build-and-push (push) Successful in 4m2s
Deploy Telegram Bot / deploy (push) Successful in 10s
When creating a session with an image, send it as a single SendPhoto
with the schedule text as caption (+ reply markup), instead of two
separate messages. Falls back to two messages if caption exceeds
Telegram's 1024-char limit.

Also adds BatchMessageEditor helper that transparently handles
EditMessageText vs EditMessageCaption depending on whether the batch
message is a text or photo message. Updated all handlers and web
service to use this helper.

Version bump to 1.9.7.
2026-05-04 10:33:06 +03:00
19 changed files with 982 additions and 39 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 1.9.6
VERSION: 1.9.7
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>1.9.6</Version>
<Version>1.9.7</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+1 -1
View File
@@ -4,7 +4,7 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v1.9.6`.
**Текущая версия:** `v1.9.7`.
---
+2 -2
View File
@@ -17,7 +17,7 @@ services:
retries: 10
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.9.6
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.9.7
restart: always
depends_on:
db:
@@ -30,7 +30,7 @@ services:
- gmrelay
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:1.9.6
image: git.codeanddice.ru/toutsu/gmrelay-web:1.9.7
restart: always
depends_on:
db:
@@ -0,0 +1,438 @@
# Player List + Kick + Waitlist Promotion Implementation Plan
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Add a player list (with names) to the Web UI session views, allow GM to kick a specific player, and auto-promote the next waitlisted player.
**Architecture:** Extend `ISessionStore` with participant queries and a remove method. Update `GroupDetails.razor` to show expandable participant lists. Reuse existing `PromoteWaitlistedPlayerAsync` logic after removal.
**Tech Stack:** C# 14, Blazor SSR, Dapper, PostgreSQL
---
## Task 1: Add domain model for WebParticipant
**Objective:** Create a DTO to represent a session participant in the web layer.
**Files:**
- Modify: `src/GmRelay.Web/Services/SessionService.cs`
**Step 1: Add record**
```csharp
public sealed record WebParticipant(
Guid Id,
long TelegramId,
string DisplayName,
string? TelegramUsername,
string RsvpStatus,
string RegistrationStatus,
bool IsGm,
DateTime? RespondedAt);
```
**Step 2: Commit**
```bash
git add src/GmRelay.Web/Services/SessionService.cs
git commit -m "feat: add WebParticipant record"
```
---
## Task 2: Add GetSessionParticipantsAsync to ISessionStore
**Objective:** Retrieve all participants for a session with full player info.
**Files:**
- Modify: `src/GmRelay.Web/Services/ISessionStore.cs`
- Modify: `src/GmRelay.Web/Services/SessionService.cs`
- Modify: `src/GmRelay.Web/Services/AuthorizedSessionService.cs`
**Step 1: Add to interface**
In `ISessionStore.cs`, add:
```csharp
Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId);
```
**Step 2: Implement in SessionService**
In `SessionService.cs`, add:
```csharp
public async Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebParticipant>(
"""
SELECT sp.id AS Id,
p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm,
sp.responded_at AS RespondedAt
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
ORDER BY sp.is_gm DESC,
CASE sp.registration_status WHEN 'Active' THEN 0 ELSE 1 END,
sp.created_at
""",
new { SessionId = sessionId })).ToList();
}
```
**Step 3: Add authorized wrapper**
In `AuthorizedSessionService.cs`, add:
```csharp
public async Task<List<WebParticipant>?> GetSessionParticipantsForGmAsync(Guid sessionId, long gmId)
{
var session = await GetSessionForGmAsync(sessionId, gmId);
if (session is null)
{
return null;
}
return await sessionStore.GetSessionParticipantsAsync(sessionId);
}
```
**Step 4: Commit**
```bash
git add src/GmRelay.Web/Services/ISessionStore.cs
git add src/GmRelay.Web/Services/SessionService.cs
git add src/GmRelay.Web/Services/AuthorizedSessionService.cs
git commit -m "feat: add GetSessionParticipantsAsync"
```
---
## Task 3: Add RemovePlayerFromSessionAsync with waitlist promotion
**Objective:** Allow GM to remove a specific player; auto-promote next waitlisted player if conditions met.
**Files:**
- Modify: `src/GmRelay.Web/Services/ISessionStore.cs`
- Modify: `src/GmRelay.Web/Services/SessionService.cs`
- Modify: `src/GmRelay.Web/Services/AuthorizedSessionService.cs`
**Step 1: Add to interface**
In `ISessionStore.cs`, add:
```csharp
Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId);
```
**Step 2: Implement in SessionService**
In `SessionService.cs`, add:
```csharp
public async Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
var session = await conn.QuerySingleOrDefaultAsync<WebSession>(
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
s.max_players AS MaxPlayers,
0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.group_id = @GroupId
FOR UPDATE",
new { SessionId = sessionId, GroupId = groupId },
transaction);
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, 0);
}
// Verify participant exists in this session
var participant = await conn.QuerySingleOrDefaultAsync<WebParticipant>(
"""
SELECT sp.id AS Id,
p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm,
sp.responded_at AS RespondedAt
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.id = @ParticipantId AND sp.session_id = @SessionId
""",
new { ParticipantId = participantId, SessionId = sessionId },
transaction);
if (participant is null)
{
throw new InvalidOperationException("Участник не найден в этой сессии.");
}
bool wasActive = participant.RegistrationStatus == ParticipantRegistrationStatus.Active;
await conn.ExecuteAsync(
"DELETE FROM session_participants WHERE id = @ParticipantId",
new { ParticipantId = participantId },
transaction);
WebPromotedParticipantDto? promoted = null;
if (wasActive)
{
promoted = await conn.QuerySingleOrDefaultAsync<WebPromotedParticipantDto>(
"""
SELECT sp.id AS ParticipantRowId,
p.display_name AS DisplayName
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Waitlisted
ORDER BY sp.created_at ASC, sp.id ASC
LIMIT 1
FOR UPDATE OF sp
""",
new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
transaction);
if (promoted is not null)
{
await conn.ExecuteAsync(
"""
UPDATE session_participants
SET registration_status = @Active,
rsvp_status = @Pending,
responded_at = NULL
WHERE id = @ParticipantRowId
""",
new
{
promoted.ParticipantRowId,
Active = ParticipantRegistrationStatus.Active,
Pending = RsvpStatus.Pending
},
transaction);
}
}
await transaction.CommitAsync();
// Notifications
await bot.SendMessage(
session.TelegramChatId,
$"🚪 <b>{System.Net.WebUtility.HtmlEncode(participant.DisplayName)}</b> удален(а) из сессии «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
if (promoted is not null)
{
await bot.SendMessage(
session.TelegramChatId,
$"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
}
if (session.BatchMessageId.HasValue)
{
await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title);
}
}
```
**Step 3: Add authorized wrapper**
In `AuthorizedSessionService.cs`, add:
```csharp
public async Task RemovePlayerFromSessionForGmAsync(Guid sessionId, long gmId, Guid participantId)
{
var session = await GetSessionForGmAsync(sessionId, gmId);
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, gmId);
}
await sessionStore.RemovePlayerFromSessionAsync(sessionId, session.GroupId, participantId);
}
```
**Step 4: Commit**
```bash
git add src/GmRelay.Web/Services/ISessionStore.cs
git add src/GmRelay.Web/Services/SessionService.cs
git add src/GmRelay.Web/Services/AuthorizedSessionService.cs
git commit -m "feat: add RemovePlayerFromSessionAsync with waitlist promotion"
```
---
## Task 4: Modify GroupDetails.razor to show participant list
**Objective:** Add expandable player lists to each session row with kick buttons.
**Files:**
- Modify: `src/GmRelay.Web/Components/Pages/GroupDetails.razor`
**Step 1:** Add `participants` dictionary and `kickingParticipantId` state variables.
**Step 2:** Add `LoadParticipants(Guid sessionId)` and `KickParticipant(Guid sessionId, Guid participantId)` methods.
**Step 3:** In desktop table, add a new column or expand row with participant list.
**Step 4:** In mobile cards, add expandable participant section.
**Step 5:** Add styles to `app.css` if needed (badge styles are already present).
**Step 6:** Commit
```bash
git add src/GmRelay.Web/Components/Pages/GroupDetails.razor
git add src/GmRelay.Web/wwwroot/app.css
git commit -m "feat: show player list and kick button in GroupDetails"
```
---
## Task 5: Modify EditSession.razor to show participant list
**Objective:** Show participant list on the edit page with kick capability.
**Files:**
- Modify: `src/GmRelay.Web/Components/Pages/EditSession.razor`
**Step 1:** Load participants in `OnInitializedAsync`.
**Step 2:** Render participant list below the edit form.
**Step 3:** Add kick button for each non-GM participant.
**Step 4:** Commit
```bash
git add src/GmRelay.Web/Components/Pages/EditSession.razor
git commit -m "feat: show player list and kick button in EditSession"
```
---
## Task 6: Add backend tests
**Objective:** Cover new GetSessionParticipants and RemovePlayerFromSession logic.
**Files:**
- Create: `tests/GmRelay.Bot.Tests/Web/SessionParticipantTests.cs`
**Step 1:** Write tests for `GetSessionParticipantsForGmAsync`.
**Step 2:** Write tests for `RemovePlayerFromSessionForGmAsync` including waitlist promotion.
**Step 3:** Run tests
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj -v n
```
**Step 4:** Commit
```bash
git add tests/GmRelay.Bot.Tests/Web/SessionParticipantTests.cs
git commit -m "test: add SessionParticipant service tests"
```
---
## Task 7: Update README
**Objective:** Bump version and document new features.
**Files:**
- Modify: `README.md`
**Step 1:** Change version from `v1.9.6` to `v1.9.7`.
**Step 2:** Add bullet under Web Dashboard: player list with kick and auto-promote.
**Step 3:** Commit
```bash
git add README.md
git commit -m "docs: bump README to v1.9.7, document player list kick"
```
---
## Task 8: Update Wiki
**Objective:** Update `Руководство ГМа` page with player management instructions.
**Files:**
- Modify: Wiki page `Руководство ГМа`
**Step 1:** Read current wiki content via MCP.
**Step 2:** Add section about viewing player list and removing players.
**Step 3:** Update via MCP.
---
## Task 9: Push branch and run CI
**Objective:** Push branch, monitor workflow, fix issues.
**Step 1:** Push
```bash
git push -u origin feat/player-list-kick-waitlist
```
**Step 2:** Check workflow run via MCP gitea actions.
**Step 3:** Fix any issues.
---
## Task 10: Merge and create release
**Objective:** Merge PR (or fast-forward), tag, create release.
**Step 1:** Merge to main
```bash
git checkout main
git merge --no-ff feat/player-list-kick-waitlist -m "feat: player list, kick, and waitlist promotion (#X)"
```
**Step 2:** Tag v1.9.7
```bash
git tag v1.9.7
git push origin main --tags
```
**Step 3:** Create release via MCP gitea_create_release.
---
@@ -106,13 +106,13 @@ public sealed class CancelSessionHandler(
try
{
await bot.EditMessageText(
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: command.ChatId,
messageId: session.BatchMessageId ?? command.MessageId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
replyMarkup: renderResult.Markup,
ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия отменена!", cancellationToken: ct);
@@ -185,31 +185,63 @@ public sealed class CreateSessionHandler(
var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty<ParticipantBatchDto>());
if (imageReference is not null)
Message batchMessage;
if (imageReference is not null && renderResult.Text.Length <= 1024)
{
// Картинка + расписание умещаются в одном Telegram-фото с подписью
try
{
await botClient.SendPhoto(
batchMessage = await botClient.SendPhoto(
chatId: chatId,
messageThreadId: messageThreadId,
photo: InputFile.FromString(imageReference),
caption: $"🎲 {System.Net.WebUtility.HtmlEncode(title)}",
caption: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}", batchId);
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}, отправляем текстом", batchId);
batchMessage = await botClient.SendMessage(
chatId: chatId,
messageThreadId: messageThreadId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
}
}
else
{
// Текст слишком длинный для caption — fallback на два сообщения
if (imageReference is not null)
{
try
{
await botClient.SendPhoto(
chatId: chatId,
messageThreadId: messageThreadId,
photo: InputFile.FromString(imageReference),
caption: $"🎲 {System.Net.WebUtility.HtmlEncode(title)}",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}", batchId);
}
}
var batchMessage = await botClient.SendMessage(
chatId: chatId,
messageThreadId: messageThreadId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
batchMessage = await botClient.SendMessage(
chatId: chatId,
messageThreadId: messageThreadId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
}
await connection.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
@@ -138,13 +138,13 @@ public sealed class JoinSessionHandler(
// 4. Перерисовываем сообщение
var renderResult = SessionBatchRenderer.Render(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
await bot.EditMessageText(
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: command.ChatId,
messageId: command.MessageId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
ct);
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Основной состав заполнен. Вы добавлены в лист ожидания."
@@ -184,13 +184,13 @@ public sealed class LeaveSessionHandler(
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants);
await bot.EditMessageText(
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: command.ChatId,
messageId: command.MessageId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
ct);
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Вы удалены из листа ожидания."
@@ -164,13 +164,13 @@ public sealed class PromoteWaitlistedPlayerHandler(
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants);
await bot.EditMessageText(
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: command.ChatId,
messageId: session.BatchMessageId ?? command.MessageId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, $"{promoted.DisplayName} переведен(а) в основной состав.", cancellationToken: ct);
}
@@ -378,13 +378,13 @@ public sealed class HandleRescheduleTimeInputHandler(
var renderResult = SessionBatchRenderer.Render(
proposal.Title, batchSessions, batchParticipants);
await bot.EditMessageText(
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: proposal.TelegramChatId,
messageId: proposal.BatchMessageId.Value,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
ct);
}
else
{
@@ -306,13 +306,13 @@ public sealed class RescheduleVotingDeadlineService(
{
var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants);
await bot.EditMessageText(
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: proposal.TelegramChatId,
messageId: proposal.BatchMessageId.Value,
text: renderResult.Text,
parseMode: ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
ct);
}
else
{
@@ -0,0 +1,51 @@
using Telegram.Bot;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Shared.Rendering;
/// <summary>
/// Handles editing batch messages that may be either text or photo messages.
/// When the batch was created with SendPhoto (image + caption), we need
/// EditMessageCaption instead of EditMessageText.
/// </summary>
public static class BatchMessageEditor
{
/// <summary>
/// Edits a batch message, automatically detecting whether it is a text or photo message.
/// Tries EditMessageText first; on failure falls back to EditMessageCaption.
/// </summary>
public static async Task EditBatchMessageAsync(
ITelegramBotClient bot,
long chatId,
int messageId,
string text,
InlineKeyboardMarkup? replyMarkup,
CancellationToken ct = default)
{
try
{
await bot.EditMessageText(
chatId: chatId,
messageId: messageId,
text: text,
parseMode: ParseMode.Html,
replyMarkup: replyMarkup,
cancellationToken: ct);
}
catch (Telegram.Bot.Exceptions.ApiRequestException ex)
when (ex.Message.Contains("there is no text in the message", StringComparison.OrdinalIgnoreCase))
{
// The batch message is a photo — use EditMessageCaption instead.
// Caption is limited to 1024 chars; if text exceeds that, truncate gracefully.
var caption = text.Length <= 1024 ? text : text[..1021] + "...";
await bot.EditMessageCaption(
chatId: chatId,
messageId: messageId,
caption: caption,
parseMode: ParseMode.Html,
replyMarkup: replyMarkup,
cancellationToken: ct);
}
}
}
@@ -229,8 +229,13 @@
<tbody>
@foreach (var session in sessions)
{
var isExpanded = expandedSessions.Contains(session.Id);
<tr>
<td style="color: var(--text-primary); font-weight: 500;">@session.Title</td>
<td style="color: var(--text-primary); font-weight: 500;">
<button type="button" class="btn-gm btn-gm-link" @onclick="() => ToggleParticipants(session.Id)">
@(isExpanded ? "▼" : "▶") @session.Title
</button>
</td>
<td>@session.ScheduledAt.FormatMoscow()</td>
<td>@FormatSeats(session)</td>
<td>
@@ -255,6 +260,49 @@
</div>
</td>
</tr>
@if (isExpanded)
{
<tr>
<td colspan="6" style="padding: 0; border: none;">
<div class="participant-panel">
@if (loadingParticipantsSessionId == session.Id)
{
<div class="skeleton skeleton-text" style="width: 60%;"></div>
}
else if (participantsCache.TryGetValue(session.Id, out var participants))
{
@if (participants.Count == 0)
{
<div class="empty-state empty-state-compact">
<div class="empty-state-title">Нет участников</div>
</div>
}
else
{
<div class="participant-list">
@foreach (var p in participants)
{
<div class="participant-row">
<div class="participant-info">
<span class="participant-name">@p.DisplayName</span>
<span class="participant-username">@FormatParticipantUsername(p)</span>
<span class="status-badge @GetParticipantStatusClass(p)">@TranslateParticipantStatus(p)</span>
</div>
@if (!p.IsGm)
{
<button type="button" class="btn-gm btn-gm-danger" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(kickingParticipantId == p.Id)" @onclick="() => KickParticipant(session.Id, p.Id)">
@(kickingParticipantId == p.Id ? "⏳..." : "🚪 Исключить")
</button>
}
</div>
}
</div>
}
}
</div>
</td>
</tr>
}
}
</tbody>
</table>
@@ -264,9 +312,12 @@
<div class="session-card-mobile stagger-children">
@foreach (var session in sessions)
{
var isExpanded = expandedSessions.Contains(session.Id);
<div class="session-card">
<div class="session-card-header">
<span class="session-card-title">@session.Title</span>
<button type="button" class="btn-gm btn-gm-link" style="text-align: left; padding: 0;" @onclick="() => ToggleParticipants(session.Id)">
@(isExpanded ? "▼" : "▶") @session.Title
</button>
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
</div>
<div class="session-card-body">
@@ -294,6 +345,45 @@
</button>
}
</div>
@if (isExpanded)
{
<div class="participant-panel" style="margin-top: 0.75rem;">
@if (loadingParticipantsSessionId == session.Id)
{
<div class="skeleton skeleton-text" style="width: 60%;"></div>
}
else if (participantsCache.TryGetValue(session.Id, out var participants))
{
@if (participants.Count == 0)
{
<div class="empty-state empty-state-compact">
<div class="empty-state-title">Нет участников</div>
</div>
}
else
{
<div class="participant-list">
@foreach (var p in participants)
{
<div class="participant-row">
<div class="participant-info">
<span class="participant-name">@p.DisplayName</span>
<span class="participant-username">@FormatParticipantUsername(p)</span>
<span class="status-badge @GetParticipantStatusClass(p)">@TranslateParticipantStatus(p)</span>
</div>
@if (!p.IsGm)
{
<button type="button" class="btn-gm btn-gm-danger" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(kickingParticipantId == p.Id)" @onclick="() => KickParticipant(session.Id, p.Id)">
@(kickingParticipantId == p.Id ? "⏳..." : "🚪 Исключить")
</button>
}
</div>
}
</div>
}
}
</div>
}
</div>
}
</div>
@@ -316,6 +406,10 @@
private string? errorMessage;
private string? successMessage;
private CoGmEditModel coGmModel = new();
private Dictionary<Guid, List<WebParticipant>> participantsCache = new();
private HashSet<Guid> expandedSessions = new();
private Guid? kickingParticipantId;
private Guid? loadingParticipantsSessionId;
protected override async Task OnInitializedAsync()
{
@@ -447,6 +541,105 @@
}
}
private async Task ToggleParticipants(Guid sessionId)
{
if (expandedSessions.Contains(sessionId))
{
expandedSessions.Remove(sessionId);
return;
}
expandedSessions.Add(sessionId);
if (!participantsCache.ContainsKey(sessionId))
{
loadingParticipantsSessionId = sessionId;
try
{
var participants = await SessionService.GetSessionParticipantsForGmAsync(sessionId, telegramId);
participantsCache[sessionId] = participants ?? [];
}
catch (Exception ex)
{
errorMessage = "Не удалось загрузить участников: " + ex.Message;
expandedSessions.Remove(sessionId);
}
finally
{
loadingParticipantsSessionId = null;
}
}
}
private async Task KickParticipant(Guid sessionId, Guid participantId)
{
errorMessage = null;
successMessage = null;
kickingParticipantId = participantId;
try
{
await SessionService.RemovePlayerFromSessionForGmAsync(sessionId, telegramId, participantId);
participantsCache.Remove(sessionId);
successMessage = "Игрок исключён.";
await LoadSessions();
if (expandedSessions.Contains(sessionId))
{
await ToggleParticipants(sessionId);
}
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = ex.Message;
}
finally
{
kickingParticipantId = null;
}
}
private static string FormatParticipantUsername(WebParticipant p)
{
var username = string.IsNullOrWhiteSpace(p.TelegramUsername)
? p.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
: "@" + p.TelegramUsername;
return $"{username} · {FormatParticipantRsvp(p.RsvpStatus)}";
}
private static string FormatParticipantRsvp(string rsvp) => rsvp switch
{
RsvpStatus.Pending => "⏳ не ответил",
RsvpStatus.Confirmed => "✅ подтвердил",
RsvpStatus.Declined => "❌ отказался",
_ => rsvp
};
private static string GetParticipantStatusClass(WebParticipant p)
{
if (p.IsGm) return "status-success";
return p.RegistrationStatus switch
{
"Active" => "status-info",
"Waitlisted" => "status-warning",
_ => "status-neutral"
};
}
private static string TranslateParticipantStatus(WebParticipant p)
{
if (p.IsGm) return "ГМ";
return p.RegistrationStatus switch
{
"Active" => "Основной состав",
"Waitlisted" => "Ожидание",
_ => p.RegistrationStatus
};
}
private async Task UpdateBatchDetails(BatchBulkEditModel batch)
{
errorMessage = null;
@@ -219,6 +219,28 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
await sessionStore.RemoveGroupCoGmAsync(groupId, coGmTelegramId);
}
public async Task<List<WebParticipant>?> GetSessionParticipantsForGmAsync(Guid sessionId, long gmId)
{
var session = await GetSessionForGmAsync(sessionId, gmId);
if (session is null)
{
return null;
}
return await sessionStore.GetSessionParticipantsAsync(sessionId);
}
public async Task RemovePlayerFromSessionForGmAsync(Guid sessionId, long gmId, Guid participantId)
{
var session = await GetSessionForGmAsync(sessionId, gmId);
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, gmId);
}
await sessionStore.RemovePlayerFromSessionAsync(sessionId, session.GroupId, participantId);
}
private async Task<bool> GroupBelongsToGmAsync(Guid groupId, long gmId)
{
return await sessionStore.IsGroupManagerAsync(groupId, gmId);
@@ -25,4 +25,6 @@ public interface ISessionStore
Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt);
Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername);
Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId);
Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId);
Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId);
}
+154 -2
View File
@@ -39,6 +39,16 @@ public sealed record WebSession(
int WaitlistedPlayerCount,
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue);
public sealed record WebParticipant(
Guid Id,
long TelegramId,
string DisplayName,
string? TelegramUsername,
string RsvpStatus,
string RegistrationStatus,
bool IsGm,
DateTime? RespondedAt);
internal sealed record WebPromotedParticipantDto(Guid ParticipantRowId, string DisplayName);
internal sealed record WebDirectNotificationRecipient(long TelegramId, string DisplayName);
internal sealed record WebBatchInfo(
@@ -507,6 +517,148 @@ public sealed class SessionService(
}
}
public async Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebParticipant>(
"""
SELECT sp.id AS Id,
p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm,
sp.responded_at AS RespondedAt
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
ORDER BY sp.is_gm DESC,
CASE sp.registration_status WHEN 'Active' THEN 0 ELSE 1 END,
sp.created_at
""",
new { SessionId = sessionId })).ToList();
}
public async Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
var session = await conn.QuerySingleOrDefaultAsync<WebSession>(
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
s.max_players AS MaxPlayers,
0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.group_id = @GroupId
FOR UPDATE",
new { SessionId = sessionId, GroupId = groupId },
transaction);
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, 0);
}
var participant = await conn.QuerySingleOrDefaultAsync<WebParticipant>(
"""
SELECT sp.id AS Id,
p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm,
sp.responded_at AS RespondedAt
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.id = @ParticipantId AND sp.session_id = @SessionId
""",
new { ParticipantId = participantId, SessionId = sessionId },
transaction);
if (participant is null)
{
throw new InvalidOperationException("Участник не найден в этой сессии.");
}
bool wasActive = participant.RegistrationStatus == ParticipantRegistrationStatus.Active;
await conn.ExecuteAsync(
"DELETE FROM session_participants WHERE id = @ParticipantId",
new { ParticipantId = participantId },
transaction);
WebPromotedParticipantDto? promoted = null;
if (wasActive)
{
promoted = await conn.QuerySingleOrDefaultAsync<WebPromotedParticipantDto>(
"""
SELECT sp.id AS ParticipantRowId,
p.display_name AS DisplayName
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Waitlisted
ORDER BY sp.created_at ASC, sp.id ASC
LIMIT 1
FOR UPDATE OF sp
""",
new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
transaction);
if (promoted is not null)
{
await conn.ExecuteAsync(
"""
UPDATE session_participants
SET registration_status = @Active,
rsvp_status = @Pending,
responded_at = NULL
WHERE id = @ParticipantRowId
""",
new
{
promoted.ParticipantRowId,
Active = ParticipantRegistrationStatus.Active,
Pending = RsvpStatus.Pending
},
transaction);
}
}
await transaction.CommitAsync();
await bot.SendMessage(
session.TelegramChatId,
$"🚪 <b>{System.Net.WebUtility.HtmlEncode(participant.DisplayName)}</b> удален(а) из сессии «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
if (promoted is not null)
{
await bot.SendMessage(
session.TelegramChatId,
$"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
if (session.BatchMessageId.HasValue)
{
await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title);
}
}
else if (session.BatchMessageId.HasValue)
{
await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title);
}
}
public async Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink)
{
await using var conn = await dataSource.OpenConnectionAsync();
@@ -1048,11 +1200,11 @@ public sealed class SessionService(
var renderResult = SessionBatchRenderer.Render(title, sessions, participants);
await bot.EditMessageText(
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: chatId,
messageId: messageId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup);
}
catch (Exception ex)
+53
View File
@@ -1115,3 +1115,56 @@ body.telegram-mini-app .session-card-mobile {
padding: 1rem;
}
}
/* === Participant list === */
.participant-panel {
background: rgba(0, 0, 0, 0.2);
border-radius: 0.75rem;
padding: 0.75rem;
margin: 0.5rem 0;
}
.participant-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.participant-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 0.5rem;
gap: 0.5rem;
}
.participant-info {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
flex: 1;
}
.participant-name {
font-weight: 500;
color: var(--text-primary);
}
.participant-username {
font-size: 0.75rem;
color: var(--text-muted);
}
.btn-gm-link {
background: transparent;
border: none;
color: var(--text-primary);
font-weight: 500;
cursor: pointer;
font-size: inherit;
font-family: inherit;
padding: 0;
}