Compare commits
8 Commits
v1.9.6
..
7c737dbf45
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c737dbf45 | |||
| 52b7b3e0fd | |||
| 5dee2d87f5 | |||
| b71488097e | |||
| 6e92419cff | |||
| fdb3445bec | |||
| c1f5d96e25 | |||
| c874f7b797 |
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 1.9.6
|
||||
VERSION: 1.9.9
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,752 @@
|
||||
# Issue #23: Добавить platform identity и platform_messages в БД
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Расширить PostgreSQL-схему нейтральными platform-полями и таблицей platform_messages, чтобы Discord-адаптер мог работать без чтения `telegram_*` legacy-полей. Сохранить обратную совместимость с Telegram-сценариями.
|
||||
|
||||
**Architecture:**
|
||||
- Добавляем generic `platform` enum-like поля (`VARCHAR(50)`) + `external_*` ID-поля рядом с legacy `telegram_*`.
|
||||
- Создаём таблицу `platform_messages` для отслеживания batch-сообщений на любой платформе.
|
||||
- Миграция обратима: `telegram_*` НЕ удаляются, новые поля nullable/с дефолтами.
|
||||
- После миграции обновляем SQL-запросы в handlers (Task 8-12) чтобы сначала писать и читать через новые поля.
|
||||
|
||||
**Tech Stack:** PostgreSQL 16+, DbUp, Dapper, .NET 10, Npgsql.
|
||||
|
||||
**Milestone:** Версия 2.0 — Discord Bot MVP
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Написать миграцию V011__add_platform_identity.sql
|
||||
|
||||
**Objective:** Создать обратимую миграцию, добавляющую platform-поля и таблицу platform_messages.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/GmRelay.Bot/Migrations/V011__add_platform_identity.sql`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
```bash
|
||||
# Проверяем, что миграция ещё не существует
|
||||
ls src/GmRelay.Bot/Migrations/V011__*.sql
|
||||
# Expected: FAIL — file not found
|
||||
```
|
||||
|
||||
**Step 2: Write migration script**
|
||||
|
||||
```sql
|
||||
-- =============================================================
|
||||
-- V011: Add platform identity fields for multi-platform support
|
||||
-- Telegram remains default platform; old telegram_* fields kept.
|
||||
-- =============================================================
|
||||
|
||||
-- 1) players: add platform identity columns
|
||||
ALTER TABLE players
|
||||
ADD COLUMN IF NOT EXISTS platform VARCHAR(50) NOT NULL DEFAULT 'telegram',
|
||||
ADD COLUMN IF NOT EXISTS external_user_id VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS external_username VARCHAR(255);
|
||||
|
||||
-- Populate external_user_id/external_username from existing telegram data
|
||||
UPDATE players
|
||||
SET external_user_id = telegram_id::VARCHAR(255),
|
||||
external_username = telegram_username
|
||||
WHERE external_user_id IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_players_platform_external_user
|
||||
ON players (platform, external_user_id);
|
||||
|
||||
-- 2) game_groups: add platform identity columns
|
||||
ALTER TABLE game_groups
|
||||
ADD COLUMN IF NOT EXISTS platform VARCHAR(50) NOT NULL DEFAULT 'telegram',
|
||||
ADD COLUMN IF NOT EXISTS external_group_id VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS external_channel_id VARCHAR(255);
|
||||
|
||||
UPDATE game_groups
|
||||
SET external_group_id = telegram_chat_id::VARCHAR(255)
|
||||
WHERE external_group_id IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_game_groups_platform_external_group
|
||||
ON game_groups (platform, external_group_id);
|
||||
|
||||
-- 3) sessions: add external_channel_id for platform-neutral thread/channel tracking
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN IF NOT EXISTS external_channel_id VARCHAR(255);
|
||||
|
||||
UPDATE sessions
|
||||
SET external_channel_id = thread_id::VARCHAR(255)
|
||||
WHERE external_channel_id IS NULL AND thread_id IS NOT NULL;
|
||||
|
||||
-- 4) platform_messages: new table for tracking messages across platforms
|
||||
CREATE TABLE IF NOT EXISTS platform_messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
platform VARCHAR(50) NOT NULL,
|
||||
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
|
||||
batch_id UUID NOT NULL,
|
||||
session_id UUID REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
external_channel_id VARCHAR(255),
|
||||
external_thread_id VARCHAR(255),
|
||||
external_message_id VARCHAR(255) NOT NULL,
|
||||
purpose VARCHAR(50) NOT NULL DEFAULT 'schedule'
|
||||
CHECK (purpose IN ('schedule','reminder','confirmation','reschedule_vote','direct_notification')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_platform_messages_group_batch
|
||||
ON platform_messages (group_id, batch_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_platform_messages_session
|
||||
ON platform_messages (session_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_platform_messages_purpose
|
||||
ON platform_messages (purpose);
|
||||
|
||||
-- 5) Backfill platform_messages from existing sessions.batch_message_id data
|
||||
INSERT INTO platform_messages (platform, group_id, batch_id, session_id, external_channel_id, external_message_id, purpose)
|
||||
SELECT 'telegram',
|
||||
s.group_id,
|
||||
s.batch_id,
|
||||
s.id,
|
||||
NULL,
|
||||
s.batch_message_id::VARCHAR(255),
|
||||
'schedule'
|
||||
FROM sessions s
|
||||
WHERE s.batch_message_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM platform_messages pm
|
||||
WHERE pm.batch_id = s.batch_id AND pm.external_message_id = s.batch_message_id::VARCHAR(255)
|
||||
);
|
||||
```
|
||||
|
||||
**Step 3: Verify migration exists**
|
||||
|
||||
```bash
|
||||
ls src/GmRelay.Bot/Migrations/V011__add_platform_identity.sql
|
||||
# Expected: file exists
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/GmRelay.Bot/Migrations/V011__add_platform_identity.sql
|
||||
git commit -m "chore(db): add V011 migration — platform identity and platform_messages"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Создать shared enum PlatformKind
|
||||
|
||||
**Objective:** Ввести типизированное перечисление платформ для использования в коде и БД.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/GmRelay.Shared/Domain/PlatformKind.cs`
|
||||
- Modify: `src/GmRelay.Shared/GmRelay.Shared.csproj` (если нужен `[JsonStringEnumConverter]` или Dapper mapping)
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
```csharp
|
||||
// tests/GmRelay.Bot.Tests/Domain/PlatformKindTests.cs
|
||||
using GmRelay.Shared.Domain;
|
||||
|
||||
public class PlatformKindTests
|
||||
{
|
||||
[Fact]
|
||||
public void Telegram_HasExpectedValue()
|
||||
{
|
||||
Assert.Equal("telegram", PlatformKind.Telegram.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Discord_HasExpectedValue()
|
||||
{
|
||||
Assert.Equal("discord", PlatformKind.Discord.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equals_SameValue_ReturnsTrue()
|
||||
{
|
||||
Assert.Equal(PlatformKind.Telegram, PlatformKind.Telegram);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_KnownValue_ReturnsInstance()
|
||||
{
|
||||
var kind = PlatformKind.Parse("discord");
|
||||
Assert.Equal(PlatformKind.Discord, kind);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run:
|
||||
```bash
|
||||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PlatformKindTests"
|
||||
# Expected: FAIL — PlatformKind not found
|
||||
```
|
||||
|
||||
**Step 2: Implement PlatformKind**
|
||||
|
||||
```csharp
|
||||
// src/GmRelay.Shared/Domain/PlatformKind.cs
|
||||
namespace GmRelay.Shared.Domain;
|
||||
|
||||
public sealed class PlatformKind : IEquatable<PlatformKind>
|
||||
{
|
||||
public static readonly PlatformKind Telegram = new("telegram");
|
||||
public static readonly PlatformKind Discord = new("discord");
|
||||
|
||||
public string Value { get; }
|
||||
|
||||
private PlatformKind(string value) => Value = value;
|
||||
|
||||
public static PlatformKind Parse(string value)
|
||||
=> value.ToLowerInvariant() switch
|
||||
{
|
||||
"telegram" => Telegram,
|
||||
"discord" => Discord,
|
||||
_ => throw new ArgumentException($"Unknown platform: {value}", nameof(value))
|
||||
};
|
||||
|
||||
public bool Equals(PlatformKind? other) => other is not null && Value == other.Value;
|
||||
public override bool Equals(object? obj) => obj is PlatformKind other && Equals(other);
|
||||
public override int GetHashCode() => Value.GetHashCode(StringComparison.OrdinalIgnoreCase);
|
||||
public override string ToString() => Value;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Run test**
|
||||
|
||||
```bash
|
||||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PlatformKindTests"
|
||||
# Expected: PASS
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/GmRelay.Shared/Domain/PlatformKind.cs tests/GmRelay.Bot.Tests/Domain/PlatformKindTests.cs
|
||||
git commit -m "feat(shared): add PlatformKind value object"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Создать Dapper TypeHandler для PlatformKind
|
||||
|
||||
**Objective:** Обеспечить автоматическую сериализацию/десериализацию PlatformKind ↔ VARCHAR в PostgreSQL.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/GmRelay.Bot/Infrastructure/Database/PlatformKindTypeHandler.cs`
|
||||
- Modify: `src/GmRelay.Bot/Program.cs` или точку регистрации Dapper handlers
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
```csharp
|
||||
// tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformKindTypeHandlerTests.cs
|
||||
using Dapper;
|
||||
using GmRelay.Bot.Infrastructure.Database;
|
||||
using GmRelay.Shared.Domain;
|
||||
|
||||
public class PlatformKindTypeHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public void PlatformKind_SetValue_ReturnsString()
|
||||
{
|
||||
var handler = new PlatformKindTypeHandler();
|
||||
var param = new Npgsql.NpgsqlParameter();
|
||||
handler.SetValue(param, PlatformKind.Discord);
|
||||
Assert.Equal("discord", param.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlatformKind_Parse_ReturnsKind()
|
||||
{
|
||||
var handler = new PlatformKindTypeHandler();
|
||||
var result = handler.Parse(typeof(PlatformKind), "telegram");
|
||||
Assert.Equal(PlatformKind.Telegram, result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run:
|
||||
```bash
|
||||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PlatformKindTypeHandlerTests"
|
||||
# Expected: FAIL — handler not found
|
||||
```
|
||||
|
||||
**Step 2: Implement handler**
|
||||
|
||||
```csharp
|
||||
// src/GmRelay.Bot/Infrastructure/Database/PlatformKindTypeHandler.cs
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using System.Data;
|
||||
|
||||
namespace GmRelay.Bot.Infrastructure.Database;
|
||||
|
||||
public sealed class PlatformKindTypeHandler : SqlMapper.TypeHandler<PlatformKind>
|
||||
{
|
||||
public override void SetValue(IDbDataParameter parameter, PlatformKind value)
|
||||
{
|
||||
parameter.Value = value.Value;
|
||||
}
|
||||
|
||||
public override PlatformKind Parse(object value)
|
||||
{
|
||||
var str = value as string ?? value.ToString() ?? string.Empty;
|
||||
return PlatformKind.Parse(str);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Register handler**
|
||||
|
||||
В `Program.cs` (или в `DbMigrator` / startup extension) добавить:
|
||||
|
||||
```csharp
|
||||
SqlMapper.AddTypeHandler(new PlatformKindTypeHandler());
|
||||
```
|
||||
|
||||
**Step 4: Run test**
|
||||
|
||||
```bash
|
||||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PlatformKindTypeHandlerTests"
|
||||
# Expected: PASS
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/GmRelay.Bot/Infrastructure/Database/PlatformKindTypeHandler.cs tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformKindTypeHandlerTests.cs
|
||||
git commit -m "feat(db): add Dapper PlatformKindTypeHandler"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Создать down-migration V011__add_platform_identity.sql (rollback)
|
||||
|
||||
**Objective:** Обеспечить обратимость миграции для отката при ошибке.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/GmRelay.Bot/Migrations/V011__add_platform_identity_rollback.sql`
|
||||
|
||||
**Step 1: Write rollback script**
|
||||
|
||||
```sql
|
||||
-- Rollback for V011: remove platform identity fields and platform_messages table
|
||||
-- NOTE: This drops NEW data. Use only in emergency.
|
||||
|
||||
DROP TABLE IF EXISTS platform_messages;
|
||||
|
||||
ALTER TABLE sessions DROP COLUMN IF EXISTS external_channel_id;
|
||||
|
||||
ALTER TABLE game_groups
|
||||
DROP COLUMN IF EXISTS platform,
|
||||
DROP COLUMN IF EXISTS external_group_id,
|
||||
DROP COLUMN IF EXISTS external_channel_id;
|
||||
|
||||
ALTER TABLE players
|
||||
DROP COLUMN IF EXISTS platform,
|
||||
DROP COLUMN IF EXISTS external_user_id,
|
||||
DROP COLUMN IF EXISTS external_username;
|
||||
```
|
||||
|
||||
**Step 2: Verify file exists**
|
||||
|
||||
```bash
|
||||
ls src/GmRelay.Bot/Migrations/V011__add_platform_identity_rollback.sql
|
||||
# Expected: file exists
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/GmRelay.Bot/Migrations/V011__add_platform_identity_rollback.sql
|
||||
git commit -m "chore(db): add V011 rollback script"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Прогнать миграцию на integration-БД и проверить структуру
|
||||
|
||||
**Objective:** Убедиться, что миграция применяется без ошибок, backfill работает, индексы созданы.
|
||||
|
||||
**Files:**
|
||||
- Test: скрипт в `tests/integration/verify_v011.sql` или ручная проверка через psql
|
||||
|
||||
**Step 1: Run migrations**
|
||||
|
||||
```bash
|
||||
cd src/GmRelay.Bot
|
||||
dotnet run --migrate-only # или через DbMigrator в Program.cs
|
||||
```
|
||||
|
||||
**Step 2: Verify schema**
|
||||
|
||||
```bash
|
||||
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d players" | grep -E "platform|external"
|
||||
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d game_groups" | grep -E "platform|external"
|
||||
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d sessions" | grep external
|
||||
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d platform_messages"
|
||||
```
|
||||
|
||||
Expected: все новые поля и таблица присутствуют.
|
||||
|
||||
**Step 3: Verify backfill**
|
||||
|
||||
```bash
|
||||
psql "$GMRELAYDB_CONNECTION_STRING" -c "SELECT COUNT(*) FROM players WHERE external_user_id IS NOT NULL;"
|
||||
psql "$GMRELAYDB_CONNECTION_STRING" -c "SELECT COUNT(*) FROM game_groups WHERE external_group_id IS NOT NULL;"
|
||||
psql "$GMRELAYDB_CONNECTION_STRING" -c "SELECT COUNT(*) FROM platform_messages;"
|
||||
```
|
||||
|
||||
Expected: counts > 0 для существующих данных.
|
||||
|
||||
**Step 4: Verify rollback works**
|
||||
|
||||
```bash
|
||||
psql "$GMRELAYDB_CONNECTION_STRING" -f src/GmRelay.Bot/Migrations/V011__add_platform_identity_rollback.sql
|
||||
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d platform_messages" # should fail (table missing)
|
||||
psql "$GMRELAYDB_CONNECTION_STRING" -f src/GmRelay.Bot/Migrations/V011__add_platform_identity.sql # re-apply
|
||||
```
|
||||
|
||||
**Step 5: Commit** (если добавили тесты)
|
||||
|
||||
```bash
|
||||
git add tests/integration/verify_v011.sql
|
||||
git commit -m "test(db): add V011 integration verification"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Обновить CreateSessionHandler — писать в platform identity при создании
|
||||
|
||||
**Objective:** При создании сессии заполнять новые `external_*` поля, не ломая Telegram flow.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
```csharp
|
||||
// tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/CreateSessionHandlerTests.cs
|
||||
[Fact]
|
||||
public async Task HandleAsync_CreatesPlayerWithPlatformIdentity()
|
||||
{
|
||||
// Arrange: mock NpgsqlDataSource + ITelegramBotClient
|
||||
// Act: send /newsession
|
||||
// Assert: INSERT into players includes external_user_id = telegram_id string
|
||||
}
|
||||
```
|
||||
Run:
|
||||
```bash
|
||||
dotnet test --filter "FullyQualifiedName~CreateSessionHandlerTests"
|
||||
# Expected: FAIL — test or implementation missing
|
||||
```
|
||||
|
||||
**Step 2: Update handler SQL**
|
||||
|
||||
В `CreateSessionHandler.cs`, обновить INSERT для players:
|
||||
|
||||
```sql
|
||||
INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
|
||||
VALUES (@TgId, @Name, @Username, 'telegram', @TgId::VARCHAR(255), @Username)
|
||||
ON CONFLICT (telegram_id) DO UPDATE
|
||||
SET display_name = EXCLUDED.display_name,
|
||||
telegram_username = EXCLUDED.telegram_username,
|
||||
external_username = EXCLUDED.external_username;
|
||||
```
|
||||
|
||||
И INSERT для game_groups:
|
||||
|
||||
```sql
|
||||
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id, platform, external_group_id)
|
||||
VALUES (@ChatId, @ChatName, @GmId, 'telegram', @ChatId::VARCHAR(255))
|
||||
RETURNING id;
|
||||
```
|
||||
|
||||
**Step 3: Run handler tests**
|
||||
|
||||
```bash
|
||||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~CreateSession"
|
||||
# Expected: PASS
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs
|
||||
git commit -m "feat(sessions): write platform identity fields on session creation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Обновить JoinSessionHandler — писать в platform identity
|
||||
|
||||
**Objective:** При join записывать `platform`, `external_user_id`, `external_username`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs`
|
||||
|
||||
**Step 1: Update SQL**
|
||||
|
||||
```sql
|
||||
INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
|
||||
VALUES (@TgId, @Name, @Username, 'telegram', @TgId::VARCHAR(255), @Username)
|
||||
ON CONFLICT (telegram_id) DO UPDATE
|
||||
SET display_name = EXCLUDED.display_name,
|
||||
telegram_username = EXCLUDED.telegram_username,
|
||||
external_username = EXCLUDED.external_username
|
||||
RETURNING id;
|
||||
```
|
||||
|
||||
**Step 2: Verify no regression**
|
||||
|
||||
```bash
|
||||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~JoinSession"
|
||||
# Expected: PASS or no existing tests (manual smoke)
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs
|
||||
git commit -m "feat(sessions): write platform identity fields on join"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Обновить ListSessionsHandler — читать через platform identity
|
||||
|
||||
**Objective:** Запрос списка сессий должен работать через `external_group_id`, не ломая Telegram flow.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs`
|
||||
|
||||
**Step 1: Update SQL**
|
||||
|
||||
Заменить `g.telegram_chat_id = @ChatId` на `g.external_group_id = @ChatId::VARCHAR(255)`.
|
||||
|
||||
```sql
|
||||
SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, ...
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
||||
WHERE g.external_group_id = @ChatId::VARCHAR(255)
|
||||
AND s.status != @Cancelled
|
||||
AND s.scheduled_at > NOW()
|
||||
GROUP BY ...
|
||||
ORDER BY s.scheduled_at ASC
|
||||
```
|
||||
|
||||
Также обновить проверку прав:
|
||||
|
||||
```sql
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND p.external_user_id = @TelegramUserId::VARCHAR(255)
|
||||
) AS CanManage
|
||||
```
|
||||
|
||||
**Step 2: Run tests**
|
||||
|
||||
```bash
|
||||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~ListSessions"
|
||||
# Expected: PASS
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs
|
||||
git commit -m "feat(sessions): read group via external_group_id in list handler"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Обновить CancelSessionHandler, LeaveSessionHandler, PromoteWaitlistedPlayerHandler
|
||||
|
||||
**Objective:** Аналогично Task 7-8: обновить SQL запросы на использование `external_user_id` и `external_group_id`.
|
||||
|
||||
**Files:**
|
||||
- Modify:
|
||||
- `src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs`
|
||||
- `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs`
|
||||
- `src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs`
|
||||
|
||||
**Step 1: Update SQL in each handler**
|
||||
|
||||
Replace patterns:
|
||||
- `p.telegram_id = @TelegramUserId` → `p.external_user_id = @TelegramUserId::VARCHAR(255)`
|
||||
- `g.telegram_chat_id = @ChatId` → `g.external_group_id = @ChatId::VARCHAR(255)`
|
||||
- `s.group_id = g.id` stays
|
||||
|
||||
**Step 2: Run all session handler tests**
|
||||
|
||||
```bash
|
||||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Session"
|
||||
# Expected: all PASS
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/GmRelay.Bot/Features/Sessions/CreateSession/
|
||||
git commit -m "feat(sessions): migrate session handlers to platform identity fields"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Обновить Reschedule и Reminder handlers
|
||||
|
||||
**Objective:** Обновить reschedule handlers и reminder sender на platform identity.
|
||||
|
||||
**Files:**
|
||||
- Modify:
|
||||
- `src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs`
|
||||
- `src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs`
|
||||
- `src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs`
|
||||
- `src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs`
|
||||
|
||||
**Step 1: Update SQL**
|
||||
|
||||
Replace `telegram_id` / `telegram_chat_id` usages with `external_user_id` / `external_group_id` where это lookup.
|
||||
|
||||
**Step 2: Run tests**
|
||||
|
||||
```bash
|
||||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Reschedule"
|
||||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Reminder"
|
||||
# Expected: PASS
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/GmRelay.Bot/Features/Sessions/RescheduleSession/ src/GmRelay.Bot/Features/Reminders/ src/GmRelay.Bot/Features/Notifications/
|
||||
git commit -m "feat(reschedule,reminders): migrate to platform identity fields"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Обновить Web Dashboard queries (TelegramAuthService, GroupDetails, MiniApp)
|
||||
|
||||
**Objective:** Веб-компоненты, которые читают `telegram_id`, должны также поддерживать `external_user_id`.
|
||||
|
||||
**Files:**
|
||||
- Modify:
|
||||
- `src/GmRelay.Web/Services/TelegramAuthService.cs`
|
||||
- `src/GmRelay.Web/Components/Pages/GroupDetails.razor`
|
||||
|
||||
**Step 1: Update TelegramAuthService**
|
||||
|
||||
```sql
|
||||
SELECT p.id, p.display_name, p.telegram_username, p.external_user_id, p.external_username
|
||||
FROM players p
|
||||
WHERE p.telegram_id = @TelegramId
|
||||
OR p.external_user_id = @TelegramId::VARCHAR(255)
|
||||
```
|
||||
|
||||
**Step 2: Update GroupDetails.razor queries**
|
||||
|
||||
Replace `telegram_chat_id` lookups with `external_group_id`.
|
||||
|
||||
**Step 3: Run web tests**
|
||||
|
||||
```bash
|
||||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Web"
|
||||
# Expected: PASS
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/GmRelay.Web/
|
||||
git commit -m "feat(web): migrate web dashboard to platform identity fields"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Создать документацию по platform identity в schema notes
|
||||
|
||||
**Objective:** Зафиксировать, что `telegram_*` deprecated, а новые поля — путь вперёд.
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/db-schema/platform-migration-notes.md`
|
||||
|
||||
**Step 1: Write doc**
|
||||
|
||||
```markdown
|
||||
# Platform Identity Migration Notes
|
||||
|
||||
## Status
|
||||
- `telegram_*` fields: DEPRECATED but kept for backward compatibility.
|
||||
- `platform`, `external_*` fields: ACTIVE, used by all new handlers.
|
||||
|
||||
## Tables
|
||||
| Table | Legacy | New Fields |
|
||||
|-------|--------|-----------|
|
||||
| players | telegram_id, telegram_username | platform, external_user_id, external_username |
|
||||
| game_groups | telegram_chat_id | platform, external_group_id, external_channel_id |
|
||||
| sessions | thread_id, batch_message_id | external_channel_id |
|
||||
| platform_messages | — | NEW table tracking all batch messages |
|
||||
|
||||
## Backfill
|
||||
V011 migration backfills `external_*` from existing `telegram_*` data.
|
||||
|
||||
## Future
|
||||
V012+ (or separate migration) will eventually drop `telegram_*` once all code paths use `external_*`.
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/db-schema/platform-migration-notes.md
|
||||
git commit -m "docs(db): add platform identity migration notes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 13: Final integration smoke test
|
||||
|
||||
**Objective:** Убедиться, что весь бот стартует, миграции проходят, и основные команды работают.
|
||||
|
||||
**Step 1: Build**
|
||||
|
||||
```bash
|
||||
dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj -c Release
|
||||
# Expected: 0 errors, 0 warnings
|
||||
```
|
||||
|
||||
**Step 2: Run all tests**
|
||||
|
||||
```bash
|
||||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --no-build
|
||||
# Expected: all PASS
|
||||
```
|
||||
|
||||
**Step 3: Start bot and test /newsession + /listsessions**
|
||||
|
||||
```bash
|
||||
dotnet run --project src/GmRelay.Bot/GmRelay.Bot.csproj
|
||||
# Manual smoke in Telegram: /newsession → verify session created, players.external_user_id populated
|
||||
```
|
||||
|
||||
**Step 4: Commit final state**
|
||||
|
||||
```bash
|
||||
git status # should be clean
|
||||
git log --oneline -5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] V011 migration applied successfully with backfill
|
||||
- [ ] Rollback script tested and works
|
||||
- [ ] PlatformKind + Dapper TypeHandler present and tested
|
||||
- [ ] All session handlers use `external_*` fields
|
||||
- [ ] Telegram commands (`/newsession`, `/listsessions`, join/leave) still work
|
||||
- [ ] Web dashboard auth and group pages still work
|
||||
- [ ] All tests pass
|
||||
- [ ] No compiler warnings
|
||||
@@ -0,0 +1,4 @@
|
||||
usage: hermes mcp [-h] [--accept-hooks]
|
||||
{serve,add,remove,rm,list,ls,test,configure,config,login}
|
||||
...
|
||||
hermes mcp: error: argument mcp_action: invalid choice: 'call' (choose from 'serve', 'add', 'remove', 'rm', 'list', 'ls', 'test', 'configure', 'config', 'login')
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>1.9.6</Version>
|
||||
<Version>1.10.0</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||
|
||||
**Текущая версия:** `v1.9.6`.
|
||||
**Текущая версия:** `v1.9.9`.
|
||||
|
||||
---
|
||||
|
||||
@@ -212,5 +212,17 @@ Owner и co-GM могут открыть мобильный dashboard прямо
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Тестирование
|
||||
|
||||
Основной набор проверок запускается командой:
|
||||
|
||||
```bash
|
||||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --collect:"XPlat Code Coverage"
|
||||
```
|
||||
|
||||
Начиная с `v1.9.9`, тестовый набор включает функциональный smoke-сценарий обещаний лендинга для Telegram: batch-сессии на несколько дат, inline-кнопки записи/выхода, лимиты мест, waitlist, автоповышение игрока, голосование за перенос, direct-notification mode и перерисовку Telegram batch-поста после dashboard-изменений. Smoke работает через fake Telegram messenger и не требует внешнего Telegram API.
|
||||
|
||||
---
|
||||
|
||||
## 📜 Лицензия
|
||||
Проект распространяется под лицензией MIT. Использование в некоммерческих целях приветствуется.
|
||||
|
||||
+2
-2
@@ -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.10.0
|
||||
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.10.0
|
||||
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.
|
||||
|
||||
---
|
||||
@@ -5,6 +5,7 @@ using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
|
||||
@@ -102,17 +103,18 @@ public sealed class CancelSessionHandler(
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
// 4. Перерисовываем сообщение
|
||||
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions.ToList(), batchParticipants.ToList());
|
||||
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions.ToList(), batchParticipants.ToList());
|
||||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
|
||||
@@ -183,33 +184,66 @@ public sealed class CreateSessionHandler(
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
|
||||
|
||||
var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty<ParticipantBatchDto>());
|
||||
var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
|
||||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
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",
|
||||
|
||||
@@ -4,6 +4,7 @@ using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
|
||||
@@ -136,15 +137,16 @@ public sealed class JoinSessionHandler(
|
||||
transactionCommitted = true;
|
||||
|
||||
// 4. Перерисовываем сообщение
|
||||
var renderResult = SessionBatchRenderer.Render(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
|
||||
var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
|
||||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
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
|
||||
? "Основной состав заполнен. Вы добавлены в лист ожидания."
|
||||
|
||||
@@ -3,6 +3,7 @@ using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
|
||||
@@ -182,15 +183,16 @@ public sealed class LeaveSessionHandler(
|
||||
await transaction.CommitAsync(ct);
|
||||
transactionCommitted = true;
|
||||
|
||||
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants);
|
||||
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
|
||||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
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
|
||||
? "Вы удалены из листа ожидания."
|
||||
|
||||
@@ -3,6 +3,7 @@ using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
|
||||
@@ -162,15 +163,16 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
||||
await transaction.CommitAsync(ct);
|
||||
transactionCommitted = true;
|
||||
|
||||
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants);
|
||||
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
|
||||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
+6
-5
@@ -6,6 +6,7 @@ using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
|
||||
@@ -375,16 +376,16 @@ public sealed class HandleRescheduleTimeInputHandler(
|
||||
|
||||
if (proposal.BatchMessageId.HasValue)
|
||||
{
|
||||
var renderResult = SessionBatchRenderer.Render(
|
||||
proposal.Title, batchSessions, batchParticipants);
|
||||
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
|
||||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
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
|
||||
{
|
||||
|
||||
+6
-4
@@ -5,6 +5,7 @@ using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
|
||||
@@ -304,15 +305,16 @@ public sealed class RescheduleVotingDeadlineService(
|
||||
|
||||
if (proposal.BatchMessageId.HasValue)
|
||||
{
|
||||
var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants);
|
||||
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
|
||||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
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.Bot.Infrastructure.Telegram;
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
public static class TelegramSessionBatchRenderer
|
||||
{
|
||||
public static (string Text, InlineKeyboardMarkup Markup) Render(SessionBatchViewModel view)
|
||||
{
|
||||
var messageText = $"🎲 <b>Новые игры:</b> {System.Net.WebUtility.HtmlEncode(view.Title)}\n\n" +
|
||||
$"<b>Расписание:</b>\n\n";
|
||||
|
||||
var buttons = new List<InlineKeyboardButton[]>();
|
||||
|
||||
foreach (var session in view.Sessions)
|
||||
{
|
||||
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
|
||||
messageText += session.MaxPlayers.HasValue
|
||||
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
|
||||
: $"👥 Игроки ({session.ActivePlayerCount}):\n";
|
||||
|
||||
if (session.ActivePlayers.Count > 0)
|
||||
{
|
||||
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
|
||||
$" 👤 {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
||||
}
|
||||
else
|
||||
{
|
||||
messageText += " <i>Пока никто не записался</i>\n";
|
||||
}
|
||||
|
||||
if (session.WaitlistedPlayers.Count > 0)
|
||||
{
|
||||
messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n";
|
||||
messageText += string.Join("\n", session.WaitlistedPlayers.Select(p =>
|
||||
$" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
||||
}
|
||||
|
||||
if (GmRelay.Shared.Domain.SessionStatus.IsCancelled(session.Status))
|
||||
{
|
||||
messageText += "❌ <i>Сессия отменена</i>\n\n";
|
||||
}
|
||||
else
|
||||
{
|
||||
messageText += "\n";
|
||||
var actionRow = session.AvailableActions
|
||||
.Select(a => InlineKeyboardButton.WithCallbackData(a.Label, $"{a.ActionKey}:{a.SessionId}"))
|
||||
.ToArray();
|
||||
if (actionRow.Length > 0)
|
||||
buttons.Add(actionRow);
|
||||
}
|
||||
}
|
||||
|
||||
return (messageText, new InlineKeyboardMarkup(buttons));
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,4 @@
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Telegram.Bot" Version="22.9.5.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace GmRelay.Shared.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Заглушка для Discord-рендерера.
|
||||
/// Реальная реализация будет добавлена в проект GmRelay.DiscordBot (issue #26).
|
||||
/// </summary>
|
||||
public static class DiscordSessionBatchRenderer
|
||||
{
|
||||
public static object Render(SessionBatchViewModel view)
|
||||
{
|
||||
throw new NotImplementedException("Discord renderer will be implemented in issue #26.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace GmRelay.Shared.Rendering;
|
||||
|
||||
public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers);
|
||||
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
|
||||
@@ -1,81 +0,0 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace GmRelay.Shared.Rendering;
|
||||
|
||||
public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers);
|
||||
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
|
||||
|
||||
public static class SessionBatchRenderer
|
||||
{
|
||||
public static (string Text, InlineKeyboardMarkup Markup) Render(
|
||||
string title,
|
||||
IReadOnlyList<SessionBatchDto> sessions,
|
||||
IReadOnlyList<ParticipantBatchDto> participants)
|
||||
{
|
||||
var activeSessions = sessions.OrderBy(s => s.ScheduledAt).ToList();
|
||||
|
||||
var messageText = $"🎲 <b>Новые игры:</b> {System.Net.WebUtility.HtmlEncode(title)}\n\n" +
|
||||
$"<b>Расписание:</b>\n\n";
|
||||
|
||||
var buttons = new List<InlineKeyboardButton[]>();
|
||||
|
||||
foreach (var session in activeSessions)
|
||||
{
|
||||
var sessionPlayers = participants
|
||||
.Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Active)
|
||||
.ToList();
|
||||
var waitlistedPlayers = participants
|
||||
.Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted)
|
||||
.ToList();
|
||||
|
||||
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
|
||||
messageText += session.MaxPlayers.HasValue
|
||||
? $"👥 Места: {sessionPlayers.Count}/{session.MaxPlayers.Value}\n"
|
||||
: $"👥 Игроки ({sessionPlayers.Count}):\n";
|
||||
|
||||
if (sessionPlayers.Count > 0)
|
||||
{
|
||||
messageText += string.Join("\n", sessionPlayers.Select(p => $" 👤 {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
||||
}
|
||||
else
|
||||
{
|
||||
messageText += " <i>Пока никто не записался</i>\n";
|
||||
}
|
||||
|
||||
if (waitlistedPlayers.Count > 0)
|
||||
{
|
||||
messageText += $"⏳ Лист ожидания ({waitlistedPlayers.Count}):\n";
|
||||
messageText += string.Join("\n", waitlistedPlayers.Select(p => $" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
||||
}
|
||||
|
||||
if (SessionStatus.IsCancelled(session.Status))
|
||||
{
|
||||
messageText += "❌ <i>Сессия отменена</i>\n\n";
|
||||
}
|
||||
else
|
||||
{
|
||||
messageText += "\n";
|
||||
var dateTitle = session.ScheduledAt.FormatMoscowShort();
|
||||
buttons.Add(new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData(GetJoinButtonText(session, sessionPlayers.Count, dateTitle), $"join_session:{session.SessionId}"),
|
||||
InlineKeyboardButton.WithCallbackData($"🚪 Выйти {dateTitle}", $"leave_session:{session.SessionId}")
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return (messageText, new InlineKeyboardMarkup(buttons));
|
||||
}
|
||||
|
||||
private static string GetJoinButtonText(SessionBatchDto session, int activePlayers, string dateTitle)
|
||||
{
|
||||
if (session.MaxPlayers.HasValue && activePlayers >= session.MaxPlayers.Value)
|
||||
{
|
||||
return $"⏳ В лист ожидания {dateTitle}";
|
||||
}
|
||||
|
||||
return $"✋ На {dateTitle}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
|
||||
namespace GmRelay.Shared.Rendering;
|
||||
|
||||
public static class SessionBatchViewBuilder
|
||||
{
|
||||
public static SessionBatchViewModel Build(
|
||||
string title,
|
||||
IReadOnlyList<SessionBatchDto> sessions,
|
||||
IReadOnlyList<ParticipantBatchDto> participants)
|
||||
{
|
||||
var orderedSessions = sessions.OrderBy(s => s.ScheduledAt).ToList();
|
||||
var sessionItems = new List<SessionViewItem>();
|
||||
|
||||
foreach (var session in orderedSessions)
|
||||
{
|
||||
var activePlayers = participants
|
||||
.Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Active)
|
||||
.Select(p => new PlayerViewItem(p.DisplayName, p.TelegramUsername, p.RegistrationStatus))
|
||||
.ToList();
|
||||
|
||||
var waitlistedPlayers = participants
|
||||
.Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted)
|
||||
.Select(p => new PlayerViewItem(p.DisplayName, p.TelegramUsername, p.RegistrationStatus))
|
||||
.ToList();
|
||||
|
||||
var actions = new List<AvailableAction>();
|
||||
if (!SessionStatus.IsCancelled(session.Status))
|
||||
{
|
||||
var dateTitle = session.ScheduledAt.FormatMoscowShort();
|
||||
var joinLabel = GetJoinButtonText(session, activePlayers.Count, dateTitle);
|
||||
actions.Add(new AvailableAction("join_session", joinLabel, session.SessionId));
|
||||
actions.Add(new AvailableAction("leave_session", $"🚪 Выйти {dateTitle}", session.SessionId));
|
||||
}
|
||||
|
||||
sessionItems.Add(new SessionViewItem(
|
||||
session.SessionId,
|
||||
session.ScheduledAt,
|
||||
session.Status,
|
||||
session.MaxPlayers,
|
||||
activePlayers.Count,
|
||||
activePlayers,
|
||||
waitlistedPlayers,
|
||||
actions));
|
||||
}
|
||||
|
||||
return new SessionBatchViewModel(title, sessionItems);
|
||||
}
|
||||
|
||||
private static string GetJoinButtonText(SessionBatchDto session, int activePlayers, string dateTitle)
|
||||
{
|
||||
if (session.MaxPlayers.HasValue && activePlayers >= session.MaxPlayers.Value)
|
||||
{
|
||||
return $"⏳ В лист ожидания {dateTitle}";
|
||||
}
|
||||
|
||||
return $"✋ На {dateTitle}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
|
||||
namespace GmRelay.Shared.Rendering;
|
||||
|
||||
public sealed record SessionBatchViewModel(
|
||||
string Title,
|
||||
IReadOnlyList<SessionViewItem> Sessions);
|
||||
|
||||
public sealed record SessionViewItem(
|
||||
Guid SessionId,
|
||||
DateTime ScheduledAt,
|
||||
string Status,
|
||||
int? MaxPlayers,
|
||||
int ActivePlayerCount,
|
||||
IReadOnlyList<PlayerViewItem> ActivePlayers,
|
||||
IReadOnlyList<PlayerViewItem> WaitlistedPlayers,
|
||||
IReadOnlyList<AvailableAction> AvailableActions);
|
||||
|
||||
public sealed record PlayerViewItem(
|
||||
string DisplayName,
|
||||
string? TelegramUsername,
|
||||
string RegistrationStatus);
|
||||
|
||||
public sealed record AvailableAction(
|
||||
string ActionKey,
|
||||
string Label,
|
||||
Guid SessionId);
|
||||
@@ -56,7 +56,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="nav-version">v1.9.6</div>
|
||||
<div class="nav-version">v1.9.9</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace GmRelay.Web.Services;
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using GmRelay.Web.Services;
|
||||
|
||||
namespace GmRelay.Web.Services;
|
||||
|
||||
@@ -39,6 +40,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 +518,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();
|
||||
@@ -731,7 +884,8 @@ public sealed class SessionService(
|
||||
|
||||
await transaction.CommitAsync();
|
||||
|
||||
var renderResult = SessionBatchRenderer.Render(batchTitle, renderedSessions, Array.Empty<ParticipantBatchDto>());
|
||||
var view = SessionBatchViewBuilder.Build(batchTitle, renderedSessions, Array.Empty<ParticipantBatchDto>());
|
||||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||||
var batchMessage = await bot.SendMessage(
|
||||
chatId: chatId,
|
||||
messageThreadId: threadId,
|
||||
@@ -939,7 +1093,8 @@ public sealed class SessionService(
|
||||
|
||||
await transaction.CommitAsync();
|
||||
|
||||
var renderResult = SessionBatchRenderer.Render(template.Title, renderedSessions, Array.Empty<ParticipantBatchDto>());
|
||||
var view = SessionBatchViewBuilder.Build(template.Title, renderedSessions, Array.Empty<ParticipantBatchDto>());
|
||||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||||
var batchMessage = await bot.SendMessage(
|
||||
chatId: group.TelegramChatId,
|
||||
text: renderResult.Text,
|
||||
@@ -1046,13 +1201,14 @@ public sealed class SessionService(
|
||||
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC",
|
||||
new { BatchId = batchId })).ToList();
|
||||
|
||||
var renderResult = SessionBatchRenderer.Render(title, sessions, participants);
|
||||
var view = SessionBatchViewBuilder.Build(title, sessions, participants);
|
||||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace GmRelay.Web.Services;
|
||||
|
||||
public static class TelegramSessionBatchRenderer
|
||||
{
|
||||
public static (string Text, InlineKeyboardMarkup Markup) Render(SessionBatchViewModel view)
|
||||
{
|
||||
var messageText = $"🎲 <b>Новые игры:</b> {System.Net.WebUtility.HtmlEncode(view.Title)}\n\n" +
|
||||
$"<b>Расписание:</b>\n\n";
|
||||
|
||||
var buttons = new List<InlineKeyboardButton[]>();
|
||||
|
||||
foreach (var session in view.Sessions)
|
||||
{
|
||||
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
|
||||
messageText += session.MaxPlayers.HasValue
|
||||
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
|
||||
: $"👥 Игроки ({session.ActivePlayerCount}):\n";
|
||||
|
||||
if (session.ActivePlayers.Count > 0)
|
||||
{
|
||||
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
|
||||
$" 👤 {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
||||
}
|
||||
else
|
||||
{
|
||||
messageText += " <i>Пока никто не записался</i>\n";
|
||||
}
|
||||
|
||||
if (session.WaitlistedPlayers.Count > 0)
|
||||
{
|
||||
messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n";
|
||||
messageText += string.Join("\n", session.WaitlistedPlayers.Select(p =>
|
||||
$" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
||||
}
|
||||
|
||||
if (GmRelay.Shared.Domain.SessionStatus.IsCancelled(session.Status))
|
||||
{
|
||||
messageText += "❌ <i>Сессия отменена</i>\n\n";
|
||||
}
|
||||
else
|
||||
{
|
||||
messageText += "\n";
|
||||
var actionRow = session.AvailableActions
|
||||
.Select(a => InlineKeyboardButton.WithCallbackData(a.Label, $"{a.ActionKey}:{a.SessionId}"))
|
||||
.ToArray();
|
||||
if (actionRow.Length > 0)
|
||||
buttons.Add(actionRow);
|
||||
}
|
||||
}
|
||||
|
||||
return (messageText, new InlineKeyboardMarkup(buttons));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/* ============================================
|
||||
GM-Relay Design System v1.9.6
|
||||
GM-Relay Design System v1.9.9
|
||||
Dark RPG Dashboard Theme
|
||||
============================================ */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Features.Landing;
|
||||
|
||||
public sealed class TelegramLandingPromisesSmokeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Smoke_ShouldCoverTelegramLandingPromisesWithoutExternalTelegramApi()
|
||||
{
|
||||
var nowUtc = new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
var parseResult = NewSessionCommandParser.Parse(BuildRecurringSessionCommand(), nowUtc);
|
||||
|
||||
Assert.True(parseResult.IsValid);
|
||||
Assert.Equal(3, parseResult.ScheduledTimes.Count);
|
||||
Assert.Equal(2, parseResult.MaxPlayers);
|
||||
Assert.Equal(TimeSpan.FromDays(7), parseResult.ScheduledTimes[1] - parseResult.ScheduledTimes[0]);
|
||||
|
||||
var scenario = TelegramLandingSmokeScenario.Publish(parseResult, SessionNotificationMode.GroupAndDirect);
|
||||
var publishedCallbacks = CallbackData(scenario.LastMessage.Markup);
|
||||
|
||||
Assert.Contains("Landing Promise Smoke", scenario.LastMessage.Text);
|
||||
Assert.Equal(6, publishedCallbacks.Count);
|
||||
Assert.All(scenario.Sessions, session =>
|
||||
{
|
||||
Assert.Contains($"join_session:{session.Id}", publishedCallbacks);
|
||||
Assert.Contains($"leave_session:{session.Id}", publishedCallbacks);
|
||||
Assert.Contains(session.ScheduledAt.FormatMoscow(), scenario.LastMessage.Text);
|
||||
});
|
||||
|
||||
var firstSessionId = scenario.Sessions[0].Id;
|
||||
var alice = scenario.Join(firstSessionId, 1001, "Alice", "alice");
|
||||
var bob = scenario.Join(firstSessionId, 1002, "Bob", "bob");
|
||||
var carol = scenario.Join(firstSessionId, 1003, "Carol", "carol");
|
||||
|
||||
Assert.Equal(ParticipantRegistrationStatus.Active, scenario.RegistrationStatus(firstSessionId, alice));
|
||||
Assert.Equal(ParticipantRegistrationStatus.Active, scenario.RegistrationStatus(firstSessionId, bob));
|
||||
Assert.Equal(ParticipantRegistrationStatus.Waitlisted, scenario.RegistrationStatus(firstSessionId, carol));
|
||||
Assert.Contains("2/2", scenario.LastMessage.Text);
|
||||
Assert.Contains("@alice", scenario.LastMessage.Text);
|
||||
Assert.Contains("@bob", scenario.LastMessage.Text);
|
||||
Assert.Contains("@carol", scenario.LastMessage.Text);
|
||||
|
||||
scenario.Leave(firstSessionId, alice);
|
||||
|
||||
Assert.False(scenario.HasParticipant(firstSessionId, alice));
|
||||
Assert.Equal(ParticipantRegistrationStatus.Active, scenario.RegistrationStatus(firstSessionId, carol));
|
||||
Assert.DoesNotContain("@alice", scenario.LastMessage.Text);
|
||||
Assert.Contains("@carol", scenario.LastMessage.Text);
|
||||
|
||||
scenario.MarkRsvpConfirmed(firstSessionId, bob);
|
||||
scenario.MarkRsvpConfirmed(firstSessionId, carol);
|
||||
|
||||
var option1Id = Guid.NewGuid();
|
||||
var option2Id = Guid.NewGuid();
|
||||
var options = new[]
|
||||
{
|
||||
new RescheduleOptionDto(option1Id, 1, new DateTimeOffset(2026, 5, 29, 16, 30, 0, TimeSpan.Zero)),
|
||||
new RescheduleOptionDto(option2Id, 2, new DateTimeOffset(2026, 5, 30, 15, 0, 0, TimeSpan.Zero))
|
||||
};
|
||||
var deadline = new DateTimeOffset(2026, 5, 20, 18, 0, 0, TimeSpan.Zero);
|
||||
var voteParticipants = scenario.ActiveVoteParticipants(firstSessionId);
|
||||
var voteMessage = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
||||
scenario.Title,
|
||||
scenario.Sessions[0].ScheduledAt,
|
||||
deadline,
|
||||
options,
|
||||
voteParticipants,
|
||||
[]);
|
||||
var voteKeyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
|
||||
|
||||
Assert.Contains("Landing Promise Smoke", voteMessage);
|
||||
Assert.Contains("0/2", voteMessage);
|
||||
Assert.Contains($"reschedule_vote:{option1Id}", CallbackData(voteKeyboard));
|
||||
Assert.Contains($"reschedule_vote:{option2Id}", CallbackData(voteKeyboard));
|
||||
|
||||
var votes = voteParticipants
|
||||
.Select(participant => new RescheduleOptionVoteDto(
|
||||
option2Id,
|
||||
participant.PlayerId,
|
||||
participant.DisplayName,
|
||||
participant.TelegramUsername))
|
||||
.ToList();
|
||||
var decision = RescheduleVoteRules.SelectWinner(
|
||||
options.Select(option => new RescheduleOptionVoteCount(
|
||||
option.OptionId,
|
||||
votes.Count(vote => vote.OptionId == option.OptionId))).ToList());
|
||||
|
||||
Assert.Equal(RescheduleVoteOutcome.Approved, decision.Outcome);
|
||||
Assert.Equal(option2Id, decision.SelectedOptionId);
|
||||
|
||||
scenario.ApplyReschedule(firstSessionId, options[1].ProposedAt);
|
||||
|
||||
Assert.Equal(options[1].ProposedAt.UtcDateTime, scenario.Sessions[0].ScheduledAt);
|
||||
Assert.Equal(RsvpStatus.Pending, scenario.RsvpStatus(firstSessionId, bob));
|
||||
Assert.Equal(RsvpStatus.Pending, scenario.RsvpStatus(firstSessionId, carol));
|
||||
Assert.Contains(options[1].ProposedAt.UtcDateTime.FormatMoscow(), scenario.LastMessage.Text);
|
||||
Assert.Collection(
|
||||
scenario.Messenger.DirectMessages.Select(message => message.TelegramId).Order(),
|
||||
telegramId => Assert.Equal(1002, telegramId),
|
||||
telegramId => Assert.Equal(1003, telegramId));
|
||||
Assert.All(scenario.Messenger.DirectMessages, message =>
|
||||
{
|
||||
Assert.Contains("Landing Promise Smoke", message.Text);
|
||||
Assert.Contains(options[1].ProposedAt.UtcDateTime.FormatMoscow(), message.Text);
|
||||
});
|
||||
|
||||
var editsBeforeDashboardUpdate = scenario.Messenger.Edits.Count;
|
||||
|
||||
scenario.UpdateBatchFromDashboard("Landing Promise Smoke - Dashboard Sync");
|
||||
|
||||
Assert.True(scenario.Messenger.Edits.Count > editsBeforeDashboardUpdate);
|
||||
Assert.Contains("Landing Promise Smoke - Dashboard Sync", scenario.LastMessage.Text);
|
||||
Assert.Contains("@bob", scenario.LastMessage.Text);
|
||||
Assert.Contains("@carol", scenario.LastMessage.Text);
|
||||
}
|
||||
|
||||
private static string BuildRecurringSessionCommand() =>
|
||||
string.Join(
|
||||
'\n',
|
||||
"/newsession",
|
||||
"\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435: Landing Promise Smoke",
|
||||
"\u0412\u0440\u0435\u043c\u044f: 15.05.2026 19:30",
|
||||
"\u0418\u0433\u0440: 3",
|
||||
"\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b: 7",
|
||||
"\u041c\u0435\u0441\u0442: 2",
|
||||
"\u0421\u0441\u044b\u043b\u043a\u0430: https://example.test/table");
|
||||
|
||||
private static IReadOnlyList<string> CallbackData(InlineKeyboardMarkup markup) =>
|
||||
markup.InlineKeyboard
|
||||
.SelectMany(row => row)
|
||||
.Select(button => button.CallbackData)
|
||||
.OfType<string>()
|
||||
.ToList();
|
||||
|
||||
private sealed class TelegramLandingSmokeScenario
|
||||
{
|
||||
private readonly List<SmokeSession> sessions;
|
||||
private readonly List<SmokeParticipant> participants = [];
|
||||
private readonly SessionNotificationMode notificationMode;
|
||||
|
||||
private TelegramLandingSmokeScenario(
|
||||
string title,
|
||||
IReadOnlyList<DateTimeOffset> scheduledTimes,
|
||||
int? maxPlayers,
|
||||
SessionNotificationMode notificationMode)
|
||||
{
|
||||
Title = title;
|
||||
this.notificationMode = notificationMode;
|
||||
sessions = scheduledTimes
|
||||
.Select(scheduledAt => new SmokeSession(
|
||||
Guid.NewGuid(),
|
||||
scheduledAt.UtcDateTime,
|
||||
SessionStatus.Planned,
|
||||
maxPlayers))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public string Title { get; private set; }
|
||||
public FakeTelegramMessenger Messenger { get; } = new();
|
||||
public IReadOnlyList<SmokeSession> Sessions => sessions;
|
||||
public FakeTelegramMessage LastMessage => Messenger.LastMessage;
|
||||
|
||||
public static TelegramLandingSmokeScenario Publish(
|
||||
NewSessionParseResult parseResult,
|
||||
SessionNotificationMode notificationMode)
|
||||
{
|
||||
var scenario = new TelegramLandingSmokeScenario(
|
||||
parseResult.Title!,
|
||||
parseResult.ScheduledTimes,
|
||||
parseResult.MaxPlayers,
|
||||
notificationMode);
|
||||
|
||||
scenario.RenderBatch();
|
||||
return scenario;
|
||||
}
|
||||
|
||||
public Guid Join(Guid sessionId, long telegramId, string displayName, string? telegramUsername)
|
||||
{
|
||||
var session = sessions.Single(value => value.Id == sessionId);
|
||||
var activeParticipants = participants.Count(participant =>
|
||||
participant.SessionId == sessionId &&
|
||||
participant.RegistrationStatus == ParticipantRegistrationStatus.Active);
|
||||
var registrationStatus = SessionCapacityRules.DecideJoinStatus(
|
||||
session.MaxPlayers,
|
||||
activeParticipants);
|
||||
var playerId = Guid.NewGuid();
|
||||
|
||||
participants.Add(new SmokeParticipant(
|
||||
sessionId,
|
||||
playerId,
|
||||
telegramId,
|
||||
displayName,
|
||||
telegramUsername,
|
||||
registrationStatus,
|
||||
GmRelay.Shared.Domain.RsvpStatus.Pending,
|
||||
participants.Count));
|
||||
|
||||
RenderBatch();
|
||||
return playerId;
|
||||
}
|
||||
|
||||
public void Leave(Guid sessionId, Guid playerId)
|
||||
{
|
||||
var participant = participants.Single(value =>
|
||||
value.SessionId == sessionId &&
|
||||
value.PlayerId == playerId);
|
||||
participants.Remove(participant);
|
||||
|
||||
var session = sessions.Single(value => value.Id == sessionId);
|
||||
var activeParticipantsAfterLeave = participants.Count(value =>
|
||||
value.SessionId == sessionId &&
|
||||
value.RegistrationStatus == ParticipantRegistrationStatus.Active);
|
||||
var waitlistedParticipants = participants.Count(value =>
|
||||
value.SessionId == sessionId &&
|
||||
value.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted);
|
||||
|
||||
if (SessionCapacityRules.ShouldPromoteAfterParticipantLeaves(
|
||||
participant.RegistrationStatus,
|
||||
session.MaxPlayers,
|
||||
activeParticipantsAfterLeave,
|
||||
waitlistedParticipants))
|
||||
{
|
||||
var promoted = participants
|
||||
.Where(value =>
|
||||
value.SessionId == sessionId &&
|
||||
value.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted)
|
||||
.OrderBy(value => value.JoinOrder)
|
||||
.First();
|
||||
|
||||
promoted.RegistrationStatus = ParticipantRegistrationStatus.Active;
|
||||
promoted.RsvpStatus = GmRelay.Shared.Domain.RsvpStatus.Pending;
|
||||
}
|
||||
|
||||
RenderBatch();
|
||||
}
|
||||
|
||||
public bool HasParticipant(Guid sessionId, Guid playerId) =>
|
||||
participants.Any(value => value.SessionId == sessionId && value.PlayerId == playerId);
|
||||
|
||||
public string RegistrationStatus(Guid sessionId, Guid playerId) =>
|
||||
participants.Single(value =>
|
||||
value.SessionId == sessionId &&
|
||||
value.PlayerId == playerId).RegistrationStatus;
|
||||
|
||||
public string RsvpStatus(Guid sessionId, Guid playerId) =>
|
||||
participants.Single(value =>
|
||||
value.SessionId == sessionId &&
|
||||
value.PlayerId == playerId).RsvpStatus;
|
||||
|
||||
public void MarkRsvpConfirmed(Guid sessionId, Guid playerId)
|
||||
{
|
||||
participants.Single(value =>
|
||||
value.SessionId == sessionId &&
|
||||
value.PlayerId == playerId).RsvpStatus = GmRelay.Shared.Domain.RsvpStatus.Confirmed;
|
||||
}
|
||||
|
||||
public IReadOnlyList<VoteParticipantDto> ActiveVoteParticipants(Guid sessionId) =>
|
||||
participants
|
||||
.Where(value =>
|
||||
value.SessionId == sessionId &&
|
||||
value.RegistrationStatus == ParticipantRegistrationStatus.Active)
|
||||
.OrderBy(value => value.DisplayName)
|
||||
.Select(value => new VoteParticipantDto(
|
||||
value.PlayerId,
|
||||
value.DisplayName,
|
||||
value.TelegramUsername,
|
||||
value.TelegramId))
|
||||
.ToList();
|
||||
|
||||
public void ApplyReschedule(Guid sessionId, DateTimeOffset newScheduledAt)
|
||||
{
|
||||
sessions.Single(value => value.Id == sessionId).ScheduledAt = newScheduledAt.UtcDateTime;
|
||||
|
||||
foreach (var participant in participants.Where(value =>
|
||||
value.SessionId == sessionId &&
|
||||
value.RegistrationStatus == ParticipantRegistrationStatus.Active))
|
||||
{
|
||||
participant.RsvpStatus = GmRelay.Shared.Domain.RsvpStatus.Pending;
|
||||
}
|
||||
|
||||
RenderBatch();
|
||||
|
||||
if (!notificationMode.ShouldSendDirectMessages())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var notification = $"""
|
||||
Reschedule approved
|
||||
{Title}
|
||||
{newScheduledAt.UtcDateTime.FormatMoscow()}
|
||||
""";
|
||||
foreach (var participant in participants.Where(value =>
|
||||
value.SessionId == sessionId &&
|
||||
value.RegistrationStatus == ParticipantRegistrationStatus.Active))
|
||||
{
|
||||
Messenger.SendDirectMessage(participant.TelegramId, notification);
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateBatchFromDashboard(string title)
|
||||
{
|
||||
Title = title;
|
||||
RenderBatch();
|
||||
}
|
||||
|
||||
private void RenderBatch()
|
||||
{
|
||||
var viewModel = SessionBatchViewBuilder.Build(
|
||||
Title,
|
||||
sessions
|
||||
.Select(session => new SessionBatchDto(
|
||||
session.Id,
|
||||
session.ScheduledAt,
|
||||
session.Status,
|
||||
session.MaxPlayers))
|
||||
.ToList(),
|
||||
participants
|
||||
.Select(participant => new ParticipantBatchDto(
|
||||
participant.SessionId,
|
||||
participant.DisplayName,
|
||||
participant.TelegramUsername,
|
||||
participant.RegistrationStatus))
|
||||
.ToList());
|
||||
|
||||
var renderResult = TelegramSessionBatchRenderer.Render(viewModel);
|
||||
|
||||
if (Messenger.HasPublishedMessage)
|
||||
{
|
||||
Messenger.EditBatchMessage(renderResult.Text, renderResult.Markup);
|
||||
}
|
||||
else
|
||||
{
|
||||
Messenger.PublishBatchMessage(renderResult.Text, renderResult.Markup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeTelegramMessenger
|
||||
{
|
||||
private const int BatchMessageId = 7001;
|
||||
|
||||
public List<FakeTelegramMessage> Sends { get; } = [];
|
||||
public List<FakeTelegramMessage> Edits { get; } = [];
|
||||
public List<FakeDirectMessage> DirectMessages { get; } = [];
|
||||
public bool HasPublishedMessage => Sends.Count > 0;
|
||||
public FakeTelegramMessage LastMessage => Edits.Count > 0 ? Edits[^1] : Sends[^1];
|
||||
|
||||
public void PublishBatchMessage(string text, InlineKeyboardMarkup markup) =>
|
||||
Sends.Add(new FakeTelegramMessage(BatchMessageId, text, markup));
|
||||
|
||||
public void EditBatchMessage(string text, InlineKeyboardMarkup markup) =>
|
||||
Edits.Add(new FakeTelegramMessage(BatchMessageId, text, markup));
|
||||
|
||||
public void SendDirectMessage(long telegramId, string text) =>
|
||||
DirectMessages.Add(new FakeDirectMessage(telegramId, text));
|
||||
}
|
||||
|
||||
private sealed record FakeTelegramMessage(
|
||||
int MessageId,
|
||||
string Text,
|
||||
InlineKeyboardMarkup Markup);
|
||||
|
||||
private sealed record FakeDirectMessage(long TelegramId, string Text);
|
||||
|
||||
private sealed record SmokeSession(
|
||||
Guid Id,
|
||||
DateTime ScheduledAt,
|
||||
string Status,
|
||||
int? MaxPlayers)
|
||||
{
|
||||
public DateTime ScheduledAt { get; set; } = ScheduledAt;
|
||||
}
|
||||
|
||||
private sealed record SmokeParticipant(
|
||||
Guid SessionId,
|
||||
Guid PlayerId,
|
||||
long TelegramId,
|
||||
string DisplayName,
|
||||
string? TelegramUsername,
|
||||
string RegistrationStatus,
|
||||
string RsvpStatus,
|
||||
int JoinOrder)
|
||||
{
|
||||
public string RegistrationStatus { get; set; } = RegistrationStatus;
|
||||
public string RsvpStatus { get; set; } = RsvpStatus;
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Rendering;
|
||||
|
||||
public sealed class SessionBatchRendererTests
|
||||
{
|
||||
[Fact]
|
||||
public void Render_ShouldOrderSessionsAndSkipButtonsForCancelledSessions()
|
||||
{
|
||||
var firstSessionId = Guid.NewGuid();
|
||||
var secondSessionId = Guid.NewGuid();
|
||||
var cancelledSessionId = Guid.NewGuid();
|
||||
|
||||
var sessions = new[]
|
||||
{
|
||||
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4),
|
||||
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null),
|
||||
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2)
|
||||
};
|
||||
var participants = new[]
|
||||
{
|
||||
new ParticipantBatchDto(secondSessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
|
||||
new ParticipantBatchDto(secondSessionId, "Charlie", null, ParticipantRegistrationStatus.Waitlisted),
|
||||
new ParticipantBatchDto(cancelledSessionId, "Bob", null, ParticipantRegistrationStatus.Active)
|
||||
};
|
||||
|
||||
var result = SessionBatchRenderer.Render("Campaign", sessions, participants);
|
||||
var text = result.Text;
|
||||
var buttons = result.Markup.InlineKeyboard.SelectMany(row => row).ToList();
|
||||
|
||||
var firstIndex = text.IndexOf(sessions[2].ScheduledAt.FormatMoscow(), StringComparison.Ordinal);
|
||||
var secondIndex = text.IndexOf(sessions[0].ScheduledAt.FormatMoscow(), StringComparison.Ordinal);
|
||||
var thirdIndex = text.IndexOf(sessions[1].ScheduledAt.FormatMoscow(), StringComparison.Ordinal);
|
||||
|
||||
Assert.Contains("Campaign", text);
|
||||
Assert.True(firstIndex < secondIndex);
|
||||
Assert.True(secondIndex < thirdIndex);
|
||||
Assert.Contains("Места: 0/2", text);
|
||||
Assert.Contains("Места: 1/4", text);
|
||||
Assert.Contains("@alice", text);
|
||||
Assert.Contains("Лист ожидания (1)", text);
|
||||
Assert.Contains("Charlie", text);
|
||||
Assert.Contains("Bob", text);
|
||||
Assert.Equal(2, result.Markup.InlineKeyboard.Count());
|
||||
Assert.Collection(
|
||||
buttons.Select(button => button.CallbackData),
|
||||
callbackData => Assert.Equal($"join_session:{firstSessionId}", callbackData),
|
||||
callbackData => Assert.Equal($"leave_session:{firstSessionId}", callbackData),
|
||||
callbackData => Assert.Equal($"join_session:{secondSessionId}", callbackData),
|
||||
callbackData => Assert.Equal($"leave_session:{secondSessionId}", callbackData));
|
||||
Assert.DoesNotContain(buttons, button => button.CallbackData?.StartsWith("cancel_session:", StringComparison.Ordinal) == true);
|
||||
Assert.DoesNotContain(buttons, button => button.CallbackData?.StartsWith("reschedule_session:", StringComparison.Ordinal) == true);
|
||||
Assert.DoesNotContain(buttons, button => button.CallbackData?.StartsWith("promote_waitlist:", StringComparison.Ordinal) == true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Rendering;
|
||||
|
||||
public sealed class SessionBatchViewBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_ShouldOrderSessionsByDate()
|
||||
{
|
||||
var firstSessionId = Guid.NewGuid();
|
||||
var secondSessionId = Guid.NewGuid();
|
||||
var cancelledSessionId = Guid.NewGuid();
|
||||
|
||||
var sessions = new[]
|
||||
{
|
||||
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4),
|
||||
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null),
|
||||
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2)
|
||||
};
|
||||
var participants = new[]
|
||||
{
|
||||
new ParticipantBatchDto(secondSessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
|
||||
new ParticipantBatchDto(secondSessionId, "Charlie", null, ParticipantRegistrationStatus.Waitlisted),
|
||||
new ParticipantBatchDto(cancelledSessionId, "Bob", null, ParticipantRegistrationStatus.Active)
|
||||
};
|
||||
|
||||
var result = SessionBatchViewBuilder.Build("Campaign", sessions, participants);
|
||||
|
||||
Assert.Equal("Campaign", result.Title);
|
||||
Assert.Equal(3, result.Sessions.Count);
|
||||
Assert.Equal(firstSessionId, result.Sessions[0].SessionId);
|
||||
Assert.Equal(secondSessionId, result.Sessions[1].SessionId);
|
||||
Assert.Equal(cancelledSessionId, result.Sessions[2].SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ShouldCalculatePlayerCounts()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4) };
|
||||
var participants = new[]
|
||||
{
|
||||
new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
|
||||
new ParticipantBatchDto(sessionId, "Bob", null, ParticipantRegistrationStatus.Active),
|
||||
new ParticipantBatchDto(sessionId, "Charlie", null, ParticipantRegistrationStatus.Waitlisted)
|
||||
};
|
||||
|
||||
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||
var session = result.Sessions[0];
|
||||
|
||||
Assert.Equal(2, session.ActivePlayerCount);
|
||||
Assert.Equal(4, session.MaxPlayers);
|
||||
Assert.Equal(2, session.ActivePlayers.Count);
|
||||
Assert.Equal(1, session.WaitlistedPlayers.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ShouldIncludeActionsForActiveSessions()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4) };
|
||||
var participants = Array.Empty<ParticipantBatchDto>();
|
||||
|
||||
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||
var actions = result.Sessions[0].AvailableActions;
|
||||
|
||||
Assert.Equal(2, actions.Count);
|
||||
Assert.Equal("join_session", actions[0].ActionKey);
|
||||
Assert.Equal("leave_session", actions[1].ActionKey);
|
||||
Assert.Equal(sessionId, actions[0].SessionId);
|
||||
Assert.Equal(sessionId, actions[1].SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ShouldNotIncludeActionsForCancelledSessions()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Cancelled, null) };
|
||||
var participants = Array.Empty<ParticipantBatchDto>();
|
||||
|
||||
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||
|
||||
Assert.Empty(result.Sessions[0].AvailableActions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ShouldMarkWaitlistActionWhenFull()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1) };
|
||||
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
|
||||
|
||||
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||
var joinAction = result.Sessions[0].AvailableActions.First(a => a.ActionKey == "join_session");
|
||||
|
||||
Assert.Contains("ожидания", joinAction.Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ShouldIncludePlayerUsernames()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, null) };
|
||||
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
|
||||
|
||||
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||
var player = result.Sessions[0].ActivePlayers[0];
|
||||
|
||||
Assert.Equal("Alice", player.DisplayName);
|
||||
Assert.Equal("alice", player.TelegramUsername);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Rendering;
|
||||
|
||||
public sealed class TelegramSessionBatchRendererTests
|
||||
{
|
||||
[Fact]
|
||||
public void Render_ShouldProduceSameTextAsOldRenderer()
|
||||
{
|
||||
var firstSessionId = Guid.NewGuid();
|
||||
var secondSessionId = Guid.NewGuid();
|
||||
var cancelledSessionId = Guid.NewGuid();
|
||||
|
||||
var sessions = new[]
|
||||
{
|
||||
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4),
|
||||
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null),
|
||||
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2)
|
||||
};
|
||||
var participants = new[]
|
||||
{
|
||||
new ParticipantBatchDto(secondSessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
|
||||
new ParticipantBatchDto(secondSessionId, "Charlie", null, ParticipantRegistrationStatus.Waitlisted),
|
||||
new ParticipantBatchDto(cancelledSessionId, "Bob", null, ParticipantRegistrationStatus.Active)
|
||||
};
|
||||
|
||||
// Old renderer output
|
||||
var (oldText, oldMarkup) = SessionBatchRenderer.Render("Campaign", sessions, participants);
|
||||
|
||||
// New pipeline output
|
||||
var view = SessionBatchViewBuilder.Build("Campaign", sessions, participants);
|
||||
var (newText, newMarkup) = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
Assert.Equal(oldText, newText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldProduceSameButtonsAsOldRenderer()
|
||||
{
|
||||
var firstSessionId = Guid.NewGuid();
|
||||
var secondSessionId = Guid.NewGuid();
|
||||
var cancelledSessionId = Guid.NewGuid();
|
||||
|
||||
var sessions = new[]
|
||||
{
|
||||
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4),
|
||||
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null),
|
||||
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2)
|
||||
};
|
||||
var participants = new[]
|
||||
{
|
||||
new ParticipantBatchDto(secondSessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
|
||||
new ParticipantBatchDto(secondSessionId, "Charlie", null, ParticipantRegistrationStatus.Waitlisted),
|
||||
new ParticipantBatchDto(cancelledSessionId, "Bob", null, ParticipantRegistrationStatus.Active)
|
||||
};
|
||||
|
||||
var (_, oldMarkup) = SessionBatchRenderer.Render("Campaign", sessions, participants);
|
||||
var view = SessionBatchViewBuilder.Build("Campaign", sessions, participants);
|
||||
var (_, newMarkup) = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
var oldButtons = oldMarkup.InlineKeyboard.SelectMany(row => row).ToList();
|
||||
var newButtons = newMarkup.InlineKeyboard.SelectMany(row => row).ToList();
|
||||
|
||||
Assert.Equal(oldButtons.Count, newButtons.Count);
|
||||
for (int i = 0; i < oldButtons.Count; i++)
|
||||
{
|
||||
Assert.Equal(oldButtons[i].CallbackData, newButtons[i].CallbackData);
|
||||
Assert.Equal(oldButtons[i].Text, newButtons[i].Text);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldSkipButtonsForCancelledSessions()
|
||||
{
|
||||
var cancelledSessionId = Guid.NewGuid();
|
||||
var sessions = new[] { new SessionBatchDto(cancelledSessionId, DateTime.UtcNow, SessionStatus.Cancelled, null) };
|
||||
var participants = Array.Empty<ParticipantBatchDto>();
|
||||
|
||||
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||
var (_, markup) = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
Assert.Empty(markup.InlineKeyboard);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldShowWaitlistButtonWhenFull()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1) };
|
||||
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
|
||||
|
||||
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||
var (_, markup) = TelegramSessionBatchRenderer.Render(view);
|
||||
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
|
||||
var joinButton = buttons.First(b => b.CallbackData?.StartsWith("join_session:") == true);
|
||||
|
||||
Assert.Contains("ожидания", joinButton.Text);
|
||||
}
|
||||
}
|
||||
@@ -655,6 +655,10 @@ public sealed class AuthorizedSessionServiceTests
|
||||
public string? LastAddedCoGmUsername { get; private set; }
|
||||
public Guid? LastRemovedCoGmGroupId { get; private set; }
|
||||
public long? LastRemovedCoGmTelegramId { get; private set; }
|
||||
public bool RemovePlayerCalled { get; private set; }
|
||||
public Guid? LastRemovedPlayerSessionId { get; private set; }
|
||||
public Guid? LastRemovedPlayerGroupId { get; private set; }
|
||||
public Guid? LastRemovedPlayerParticipantId { get; private set; }
|
||||
|
||||
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
|
||||
Task.FromResult(groupsById.Values.Where(group => IsManager(group.Id, gmId)).ToList());
|
||||
@@ -870,6 +874,18 @@ public sealed class AuthorizedSessionServiceTests
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId) =>
|
||||
Task.FromResult(new List<WebParticipant>());
|
||||
|
||||
public Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId)
|
||||
{
|
||||
RemovePlayerCalled = true;
|
||||
LastRemovedPlayerSessionId = sessionId;
|
||||
LastRemovedPlayerGroupId = groupId;
|
||||
LastRemovedPlayerParticipantId = participantId;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private bool IsManager(Guid groupId, long telegramId) =>
|
||||
IsOwner(groupId, telegramId) ||
|
||||
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class CampaignTemplatesNavigationTests
|
||||
@@ -11,6 +13,20 @@ public sealed class CampaignTemplatesNavigationTests
|
||||
Assert.Contains("Шаблоны", navMenu, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NavMenu_ShouldExposeCurrentProjectVersion()
|
||||
{
|
||||
var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor"));
|
||||
var props = XDocument.Load(FindRepositoryFile("Directory.Build.props"));
|
||||
var version = props.Root?
|
||||
.Element("PropertyGroup")?
|
||||
.Element("Version")?
|
||||
.Value;
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(version));
|
||||
Assert.Contains($"v{version}", navMenu, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NavMenuStyles_ShouldStyleNavLinkAnchorsAsStackedRows()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user