chore: remove AI working directories (docs/superpowers, docs/plans) from repo
Add docs/superpowers/, docs/plans/, *.diff to .gitignore. These directories contain implementation plans and design specs used during agentic development; they are not needed in source control. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BIN
Binary file not shown.
@@ -1,438 +0,0 @@
|
|||||||
# 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.
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# Telegram Mini App Dashboard Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Add a Telegram Mini App mobile dashboard that reuses the existing Web Dashboard and validates Telegram WebApp `initData` on the server.
|
|
||||||
|
|
||||||
**Architecture:** Extend `TelegramAuthService` for WebApp init data, add a `/miniapp` Blazor entry page plus `/auth/telegram-webapp` endpoint, and add bot entry points through an inline WebApp button and optional menu button setup. Existing application/domain services remain the only write path.
|
|
||||||
|
|
||||||
**Tech Stack:** .NET 10, Blazor Server, Telegram.Bot, xUnit, Dapper/Npgsql-backed existing services.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Telegram WebApp Authentication
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/GmRelay.Web/Services/TelegramAuthService.cs`
|
|
||||||
- Modify: `src/GmRelay.Web/Program.cs`
|
|
||||||
- Test: `tests/GmRelay.Bot.Tests/Web/TelegramAuthServiceTests.cs`
|
|
||||||
|
|
||||||
- [ ] Write failing tests for valid WebApp `initData`, tampered hash, and expired auth date.
|
|
||||||
- [ ] Run `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramAuthServiceTests`.
|
|
||||||
- [ ] Implement WebApp HMAC verification using the Telegram `WebAppData` secret derivation.
|
|
||||||
- [ ] Add `/auth/telegram-webapp` endpoint that signs in using the same claims as `/auth/telegram`.
|
|
||||||
- [ ] Re-run the filtered tests.
|
|
||||||
|
|
||||||
### Task 2: Mini App Entry Page
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/GmRelay.Web/Components/Pages/MiniApp.razor`
|
|
||||||
- Modify: `src/GmRelay.Web/Components/App.razor`
|
|
||||||
- Modify: `src/GmRelay.Web/wwwroot/app.css`
|
|
||||||
- Test: `tests/GmRelay.Bot.Tests/Web/MiniAppDashboardTests.cs`
|
|
||||||
|
|
||||||
- [ ] Write failing tests that assert `/miniapp`, `telegram-web-app.js`, `authenticateTelegramMiniApp`, and Mini App CSS hooks exist.
|
|
||||||
- [ ] Run `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter MiniAppDashboardTests`.
|
|
||||||
- [ ] Implement `/miniapp` to post `Telegram.WebApp.initData` to `/auth/telegram-webapp`, expand/ready the Mini App, and show fallback login when opened outside Telegram.
|
|
||||||
- [ ] Add CSS for a mobile-first Mini App shell and compact dashboard spacing.
|
|
||||||
- [ ] Re-run the filtered tests.
|
|
||||||
|
|
||||||
### Task 3: Bot Entry Points
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramMiniAppMenuButtonService.cs`
|
|
||||||
- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs`
|
|
||||||
- Modify: `src/GmRelay.Bot/Program.cs`
|
|
||||||
- Test: `tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramMiniAppEntryPointTests.cs`
|
|
||||||
|
|
||||||
- [ ] Write failing tests that assert `/start` exposes a WebApp button and startup registers the menu button service.
|
|
||||||
- [ ] Run `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramMiniAppEntryPointTests`.
|
|
||||||
- [ ] Add a configurable `Telegram:MiniAppUrl` entry point; when missing, keep existing command behavior.
|
|
||||||
- [ ] Add hosted service that calls `SetChatMenuButton` with `MenuButtonWebApp` only when the URL is configured.
|
|
||||||
- [ ] Re-run the filtered tests.
|
|
||||||
|
|
||||||
### Task 4: Docs, Versions, and Release Prep
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `Directory.Build.props`
|
|
||||||
- Modify: `compose.yaml`
|
|
||||||
- Modify: `.gitea/workflows/deploy.yml`
|
|
||||||
- Modify: `src/GmRelay.Web/wwwroot/app.css`
|
|
||||||
- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor`
|
|
||||||
- Modify: `README.md`
|
|
||||||
- Wiki: `Home`, `Быстрый старт`, `Руководство ГМа`, `Развёртывание`, `Архитектура`, `Разработка`
|
|
||||||
|
|
||||||
- [ ] Update project/container/workflow/UI versions to `1.9.0`.
|
|
||||||
- [ ] Document `TELEGRAM_MINI_APP_URL`, BotFather `/setmenubutton`, `/miniapp`, and WebApp auth.
|
|
||||||
- [ ] Run `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --collect:"XPlat Code Coverage"`.
|
|
||||||
- [ ] Run `dotnet build GM-Relay.slnx -c Release`.
|
|
||||||
- [ ] Commit, push, close issue #17, update wiki, create tag/release `v1.9.0`.
|
|
||||||
@@ -1,560 +0,0 @@
|
|||||||
# Platform Messenger Contracts Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Implement issue #24 by adding platform-neutral platform identity and messaging contracts, then routing the Telegram session flows through a Telegram adapter without changing Telegram behavior.
|
|
||||||
|
|
||||||
**Architecture:** Keep update routing and Telegram update parsing at the `GmRelay.Bot.Infrastructure.Telegram` boundary, but move outbound messaging decisions behind `GmRelay.Shared.Platform.IPlatformMessenger`. `GmRelay.Shared` owns platform-neutral DTOs and contracts; `GmRelay.Bot` owns `TelegramPlatformMessenger`, which translates neutral requests into `Telegram.Bot` calls and reuses the existing Telegram renderers/editing rules.
|
|
||||||
|
|
||||||
**Tech Stack:** .NET 10, C# preview, xUnit, Dapper.AOT constraints, Telegram.Bot in `GmRelay.Bot` only, platform-neutral shared contracts in `GmRelay.Shared`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Issue Context
|
|
||||||
|
|
||||||
- Gitea issue: #24, `refactor: ввести PlatformKind, PlatformUser, PlatformGroup и IPlatformMessenger`
|
|
||||||
- Labels: `area:bot`, `area:platform`, `area:shared`, `platform:multi`, `type:refactor`, `pending-approval`
|
|
||||||
- Acceptance criteria:
|
|
||||||
- New contracts live in a platform-neutral layer.
|
|
||||||
- Telegram flow goes through the adapter without behavior changes.
|
|
||||||
- A future DiscordBot can reference the contract without depending on Telegram assemblies.
|
|
||||||
|
|
||||||
## Proposed Version Bump
|
|
||||||
|
|
||||||
Current version is `2.0.0` in:
|
|
||||||
|
|
||||||
- `Directory.Build.props`
|
|
||||||
- `compose.yaml`
|
|
||||||
- `.gitea/workflows/deploy.yml`
|
|
||||||
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
|
|
||||||
|
|
||||||
Issue label is `type:refactor`; per workflow rules this is not a major bump and has no user-facing feature label. Proposed bump: `2.0.0` -> `2.0.1`.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
- Create: `src/GmRelay.Shared/Platform/PlatformKind.cs`
|
|
||||||
- Create: `src/GmRelay.Shared/Platform/PlatformUser.cs`
|
|
||||||
- Create: `src/GmRelay.Shared/Platform/PlatformGroup.cs`
|
|
||||||
- Create: `src/GmRelay.Shared/Platform/PlatformMessageContracts.cs`
|
|
||||||
- Create: `src/GmRelay.Shared/Platform/IPlatformMessenger.cs`
|
|
||||||
- Create: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs`
|
|
||||||
- Create: `tests/GmRelay.Bot.Tests/Platform/PlatformContractsTests.cs`
|
|
||||||
- Create: `tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs`
|
|
||||||
- Modify: `src/GmRelay.Bot/Program.cs`
|
|
||||||
- Modify: `src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs`
|
|
||||||
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs`
|
|
||||||
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs`
|
|
||||||
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs`
|
|
||||||
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs`
|
|
||||||
- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs`
|
|
||||||
- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs`
|
|
||||||
- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs`
|
|
||||||
- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs`
|
|
||||||
- Modify: `src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs`
|
|
||||||
- Modify: version files listed above
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### Shared Contracts
|
|
||||||
|
|
||||||
`PlatformKind` is a sentinel enum where `Max` is not a sendable platform:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
namespace GmRelay.Shared.Platform;
|
|
||||||
|
|
||||||
public enum PlatformKind
|
|
||||||
{
|
|
||||||
Telegram = 0,
|
|
||||||
Discord = 1,
|
|
||||||
Max = 2
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`PlatformUser` and `PlatformGroup` carry external platform identity while keeping current Telegram IDs representable as strings:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
namespace GmRelay.Shared.Platform;
|
|
||||||
|
|
||||||
public sealed record PlatformUser(
|
|
||||||
PlatformKind Platform,
|
|
||||||
string ExternalUserId,
|
|
||||||
string DisplayName,
|
|
||||||
string? ExternalUsername);
|
|
||||||
|
|
||||||
public sealed record PlatformGroup(
|
|
||||||
PlatformKind Platform,
|
|
||||||
string ExternalGroupId,
|
|
||||||
string DisplayName,
|
|
||||||
string? ExternalChannelId = null,
|
|
||||||
string? ExternalThreadId = null);
|
|
||||||
```
|
|
||||||
|
|
||||||
Outbound message contracts stay independent of Telegram/Discord SDK types:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using GmRelay.Shared.Rendering;
|
|
||||||
|
|
||||||
namespace GmRelay.Shared.Platform;
|
|
||||||
|
|
||||||
public sealed record PlatformMessageRef(
|
|
||||||
PlatformKind Platform,
|
|
||||||
string ExternalGroupId,
|
|
||||||
string? ExternalThreadId,
|
|
||||||
string ExternalMessageId);
|
|
||||||
|
|
||||||
public sealed record PlatformMessageAction(
|
|
||||||
string Key,
|
|
||||||
string Label,
|
|
||||||
string Payload);
|
|
||||||
|
|
||||||
public sealed record PlatformScheduleMessage(
|
|
||||||
PlatformGroup Group,
|
|
||||||
SessionBatchViewModel View,
|
|
||||||
PlatformMessageRef? ExistingMessage,
|
|
||||||
string? ImageReference = null);
|
|
||||||
|
|
||||||
public sealed record PlatformPrivateMessage(
|
|
||||||
PlatformUser Recipient,
|
|
||||||
string HtmlText);
|
|
||||||
|
|
||||||
public sealed record PlatformInteractionReply(
|
|
||||||
string InteractionId,
|
|
||||||
string Text,
|
|
||||||
bool ShowAlert = false);
|
|
||||||
|
|
||||||
public sealed record PlatformCalendarFile(
|
|
||||||
PlatformGroup Group,
|
|
||||||
string FileName,
|
|
||||||
byte[] Content,
|
|
||||||
string CaptionHtml,
|
|
||||||
IReadOnlyList<PlatformMessageAction> Actions);
|
|
||||||
```
|
|
||||||
|
|
||||||
`IPlatformMessenger` exposes the required outward operations:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
namespace GmRelay.Shared.Platform;
|
|
||||||
|
|
||||||
public interface IPlatformMessenger
|
|
||||||
{
|
|
||||||
Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
|
|
||||||
Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
|
|
||||||
Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct);
|
|
||||||
Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct);
|
|
||||||
Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct);
|
|
||||||
Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Telegram Adapter
|
|
||||||
|
|
||||||
`TelegramPlatformMessenger` lives in `GmRelay.Bot.Infrastructure.Telegram`, depends on `ITelegramBotClient`, and translates neutral DTOs to existing Telegram calls:
|
|
||||||
|
|
||||||
- `SendScheduleAsync` renders `SessionBatchViewModel` with `TelegramSessionBatchRenderer.Render`.
|
|
||||||
- `UpdateScheduleAsync` calls `BatchMessageEditor.EditBatchMessageAsync`.
|
|
||||||
- `SendGroupMessageAsync` calls `SendMessage` with `ParseMode.Html` and optional `messageThreadId`.
|
|
||||||
- `SendPrivateMessageAsync` calls `SendMessage` to `PlatformUser.ExternalUserId`.
|
|
||||||
- `AnswerInteractionAsync` calls `AnswerCallbackQuery`.
|
|
||||||
- `SendCalendarFileAsync` calls `SendDocument` and maps URL actions to inline keyboard buttons.
|
|
||||||
|
|
||||||
### Handler Scope
|
|
||||||
|
|
||||||
Refactor outbound Telegram calls in these flows to `IPlatformMessenger`:
|
|
||||||
|
|
||||||
- Join/leave/promote waitlist schedule updates and callback replies.
|
|
||||||
- Cancel schedule update, group cancellation message, direct notification and callback reply.
|
|
||||||
- Reschedule initiation, voting message updates, immediate reschedule schedule update, direct notifications and callback replies.
|
|
||||||
- Export calendar file sending.
|
|
||||||
|
|
||||||
Keep Telegram inbound DTOs at the boundary for now:
|
|
||||||
|
|
||||||
- `UpdateRouter` still receives `Telegram.Bot.Types.Update`.
|
|
||||||
- Text message parsing in reschedule input still receives `Telegram.Bot.Types.Message`.
|
|
||||||
- `CreateSessionHandler` can keep photo/topic creation via `ITelegramBotClient` because issue #24 targets outbound schedule/interaction/private/calendar contract, not replacing all Telegram update primitives in one PR.
|
|
||||||
|
|
||||||
## Tasks
|
|
||||||
|
|
||||||
### Task 1: RED - Shared Contract Tests
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `tests/GmRelay.Bot.Tests/Platform/PlatformContractsTests.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing tests for neutral contracts**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using GmRelay.Shared.Platform;
|
|
||||||
using GmRelay.Shared.Rendering;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Platform;
|
|
||||||
|
|
||||||
public sealed class PlatformContractsTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void PlatformKind_ShouldDefineTelegramDiscordAndMaxSentinel()
|
|
||||||
{
|
|
||||||
Assert.Equal(0, (int)PlatformKind.Telegram);
|
|
||||||
Assert.Equal(1, (int)PlatformKind.Discord);
|
|
||||||
Assert.Equal(2, (int)PlatformKind.Max);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void PlatformContracts_ShouldBeTelegramAssemblyFree()
|
|
||||||
{
|
|
||||||
var contractTypes = new[]
|
|
||||||
{
|
|
||||||
typeof(PlatformUser),
|
|
||||||
typeof(PlatformGroup),
|
|
||||||
typeof(PlatformMessageRef),
|
|
||||||
typeof(PlatformMessageAction),
|
|
||||||
typeof(PlatformScheduleMessage),
|
|
||||||
typeof(PlatformPrivateMessage),
|
|
||||||
typeof(PlatformInteractionReply),
|
|
||||||
typeof(PlatformCalendarFile),
|
|
||||||
typeof(IPlatformMessenger)
|
|
||||||
};
|
|
||||||
|
|
||||||
Assert.All(contractTypes, type =>
|
|
||||||
Assert.DoesNotContain(
|
|
||||||
"Telegram",
|
|
||||||
string.Join(" ", type.Assembly.GetReferencedAssemblies().Select(value => value.Name)),
|
|
||||||
StringComparison.OrdinalIgnoreCase));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void PlatformScheduleMessage_ShouldCarrySharedViewModelWithoutPlatformTypes()
|
|
||||||
{
|
|
||||||
var sessionId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
|
||||||
var view = SessionBatchViewBuilder.Build(
|
|
||||||
"Campaign",
|
|
||||||
[new SessionBatchDto(sessionId, new DateTime(2026, 5, 15, 16, 0, 0, DateTimeKind.Utc), "Planned", 4, "https://example.test/game")],
|
|
||||||
[]);
|
|
||||||
var group = new PlatformGroup(PlatformKind.Discord, "guild-1", "Guild", "channel-1", "thread-1");
|
|
||||||
|
|
||||||
var message = new PlatformScheduleMessage(group, view, ExistingMessage: null);
|
|
||||||
|
|
||||||
Assert.Equal(PlatformKind.Discord, message.Group.Platform);
|
|
||||||
Assert.Same(view, message.View);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests and verify RED**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter PlatformContractsTests
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: compile failure because `GmRelay.Shared.Platform` types do not exist.
|
|
||||||
|
|
||||||
### Task 2: GREEN - Add Shared Contracts
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/GmRelay.Shared/Platform/PlatformKind.cs`
|
|
||||||
- Create: `src/GmRelay.Shared/Platform/PlatformUser.cs`
|
|
||||||
- Create: `src/GmRelay.Shared/Platform/PlatformGroup.cs`
|
|
||||||
- Create: `src/GmRelay.Shared/Platform/PlatformMessageContracts.cs`
|
|
||||||
- Create: `src/GmRelay.Shared/Platform/IPlatformMessenger.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add the contract files exactly as described in the Design section**
|
|
||||||
- [ ] **Step 2: Run PlatformContractsTests and verify GREEN**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter PlatformContractsTests
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `Passed`.
|
|
||||||
|
|
||||||
### Task 3: RED - Adapter and Flow Source Tests
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write source tests for adapter wiring and target flows**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
namespace GmRelay.Bot.Tests.Infrastructure.Telegram;
|
|
||||||
|
|
||||||
public sealed class TelegramPlatformMessengerSourceTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task Program_ShouldRegisterTelegramPlatformMessenger()
|
|
||||||
{
|
|
||||||
var program = await ReadRepositoryFileAsync("src/GmRelay.Bot/Program.cs");
|
|
||||||
|
|
||||||
Assert.Contains("IPlatformMessenger", program, StringComparison.Ordinal);
|
|
||||||
Assert.Contains("TelegramPlatformMessenger", program, StringComparison.Ordinal);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs")]
|
|
||||||
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs")]
|
|
||||||
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs")]
|
|
||||||
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs")]
|
|
||||||
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs")]
|
|
||||||
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs")]
|
|
||||||
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs")]
|
|
||||||
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs")]
|
|
||||||
[InlineData("src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs")]
|
|
||||||
public async Task SessionFlows_ShouldUsePlatformMessengerForOutboundTelegramWork(string relativePath)
|
|
||||||
{
|
|
||||||
var source = await ReadRepositoryFileAsync(relativePath);
|
|
||||||
|
|
||||||
Assert.Contains("IPlatformMessenger", source, StringComparison.Ordinal);
|
|
||||||
Assert.DoesNotContain("BatchMessageEditor.EditBatchMessageAsync", source, StringComparison.Ordinal);
|
|
||||||
Assert.DoesNotContain(".AnswerCallbackQuery(", source, StringComparison.Ordinal);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task TelegramPlatformMessenger_ShouldOwnTelegramBotClientCalls()
|
|
||||||
{
|
|
||||||
var source = await ReadRepositoryFileAsync("src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs");
|
|
||||||
|
|
||||||
Assert.Contains("ITelegramBotClient", source, StringComparison.Ordinal);
|
|
||||||
Assert.Contains("BatchMessageEditor.EditBatchMessageAsync", source, StringComparison.Ordinal);
|
|
||||||
Assert.Contains("AnswerCallbackQuery", source, StringComparison.Ordinal);
|
|
||||||
Assert.Contains("SendDocument", source, StringComparison.Ordinal);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
|
||||||
{
|
|
||||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
|
||||||
while (directory is not null)
|
|
||||||
{
|
|
||||||
var candidate = Path.Combine(directory.FullName, relativePath);
|
|
||||||
if (File.Exists(candidate))
|
|
||||||
{
|
|
||||||
return await File.ReadAllTextAsync(candidate);
|
|
||||||
}
|
|
||||||
|
|
||||||
directory = directory.Parent;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests and verify RED**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramPlatformMessengerSourceTests
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: failures because `TelegramPlatformMessenger` is missing and handlers still call Telegram APIs directly.
|
|
||||||
|
|
||||||
### Task 4: GREEN - Implement TelegramPlatformMessenger and Registration
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs`
|
|
||||||
- Modify: `src/GmRelay.Bot/Program.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Implement adapter**
|
|
||||||
|
|
||||||
Implementation notes:
|
|
||||||
|
|
||||||
- Parse Telegram chat/thread/message IDs from neutral string IDs with `long.Parse` and `int.Parse`.
|
|
||||||
- Use `ParseMode.Html` for HTML text.
|
|
||||||
- Map `PlatformMessageAction` URLs to `InlineKeyboardButton.WithUrl`.
|
|
||||||
- Return a `PlatformMessageRef` with message IDs converted to strings.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Register adapter**
|
|
||||||
|
|
||||||
Add `using GmRelay.Shared.Platform;` and register:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
builder.Services.AddSingleton<IPlatformMessenger, TelegramPlatformMessenger>();
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run adapter source tests**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramPlatformMessengerSourceTests
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: some handler source tests still fail until Task 5.
|
|
||||||
|
|
||||||
### Task 5: GREEN - Refactor Session Flows Through Adapter
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify target handler files listed in Task 3
|
|
||||||
- Modify: `src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Replace constructor dependencies**
|
|
||||||
|
|
||||||
Use `IPlatformMessenger messenger` in target handlers for outbound operations. Keep `ITelegramBotClient` only where the handler still performs inbound Telegram-specific work that is out of scope, such as message deletion or forum topic creation.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Convert Telegram IDs to neutral platform objects**
|
|
||||||
|
|
||||||
Use helper code equivalent to:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private static PlatformGroup TelegramGroup(long chatId, string? title, int? threadId = null)
|
|
||||||
=> new(
|
|
||||||
PlatformKind.Telegram,
|
|
||||||
chatId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
|
||||||
title ?? "Telegram chat",
|
|
||||||
ExternalChannelId: chatId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
|
||||||
ExternalThreadId: threadId?.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
|
||||||
|
|
||||||
private static PlatformUser TelegramUser(long telegramId, string displayName, string? username = null)
|
|
||||||
=> new(
|
|
||||||
PlatformKind.Telegram,
|
|
||||||
telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
|
||||||
displayName,
|
|
||||||
username);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Replace schedule updates**
|
|
||||||
|
|
||||||
Build `SessionBatchViewModel` as before, then call:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
await messenger.UpdateScheduleAsync(
|
|
||||||
new PlatformScheduleMessage(
|
|
||||||
group,
|
|
||||||
view,
|
|
||||||
new PlatformMessageRef(PlatformKind.Telegram, group.ExternalGroupId, group.ExternalThreadId, messageId.ToString(System.Globalization.CultureInfo.InvariantCulture))),
|
|
||||||
ct);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Replace interaction replies**
|
|
||||||
|
|
||||||
Use:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
await messenger.AnswerInteractionAsync(
|
|
||||||
new PlatformInteractionReply(command.CallbackQueryId, text, showAlert: false),
|
|
||||||
ct);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Replace direct notifications**
|
|
||||||
|
|
||||||
`DirectSessionNotificationSender` should become a small compatibility service over `IPlatformMessenger`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
await messenger.SendPrivateMessageAsync(
|
|
||||||
new PlatformPrivateMessage(
|
|
||||||
new PlatformUser(PlatformKind.Telegram, recipient.TelegramId.ToString(CultureInfo.InvariantCulture), recipient.DisplayName, null),
|
|
||||||
htmlText),
|
|
||||||
ct);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Replace calendar file sending**
|
|
||||||
|
|
||||||
`ExportCalendarHandler` builds the same ICS bytes and calls `SendCalendarFileAsync`, preserving the subscription URL button as a `PlatformMessageAction`.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Run target source tests**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramPlatformMessengerSourceTests
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `Passed`.
|
|
||||||
|
|
||||||
### Task 6: Regression Tests
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Existing tests only unless a compiler failure exposes a missing using or changed behavior.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Run rendering and routing tests**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Rendering|FullyQualifiedName~Telegram|FullyQualifiedName~RescheduleSession"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `Passed`.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run all tests**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `Passed`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Build solution**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
dotnet build GM-Relay.slnx
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `Build succeeded` with warnings treated as errors.
|
|
||||||
|
|
||||||
### Task 7: Version Bump
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `Directory.Build.props`
|
|
||||||
- Modify: `compose.yaml`
|
|
||||||
- Modify: `.gitea/workflows/deploy.yml`
|
|
||||||
- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update all four version locations to `2.0.1`**
|
|
||||||
- [ ] **Step 2: Verify sync**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
rg -n "2\\.0\\.0|2\\.0\\.1" Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no `2.0.0` matches in these files and `2.0.1` appears in all required locations.
|
|
||||||
|
|
||||||
### Task 8: Documentation Review
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Review: `README.md`
|
|
||||||
- Review: `docs/adr/002-platform-neutral-batch-rendering.md`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Check README and ADR for platform contract accuracy**
|
|
||||||
- [ ] **Step 2: Update docs if they now misrepresent platform-neutral responsibilities**
|
|
||||||
|
|
||||||
Expected likely doc change: README currently lists current version as `v1.15.0`, which is already inconsistent with repo version `2.0.0`. If this PR bumps to `2.0.1`, update that line to `v2.0.1`.
|
|
||||||
|
|
||||||
### Task 9: Commit, PR, CI, Review, Merge, Deploy, Release
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Stage only files intentionally changed for issue #24.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create branch**
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git checkout -b codex/refactor/issue-24-platform-messenger
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Commit**
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git add src/GmRelay.Shared/Platform src/GmRelay.Bot tests/GmRelay.Bot.Tests Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor README.md docs/adr/002-platform-neutral-batch-rendering.md
|
|
||||||
git commit -m "refactor: add platform messenger contracts"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Push and create PR via Gitea**
|
|
||||||
- [ ] **Step 4: Wait for PR CI and fix failures if any**
|
|
||||||
- [ ] **Step 5: Run code review subagent and address findings**
|
|
||||||
- [ ] **Step 6: Merge PR after CI and review**
|
|
||||||
- [ ] **Step 7: Monitor deploy workflow**
|
|
||||||
- [ ] **Step 8: Create release `v2.0.1` with Russian release notes**
|
|
||||||
- [ ] **Step 9: Close issue #24 with PR and release links**
|
|
||||||
|
|
||||||
## Self-Review
|
|
||||||
|
|
||||||
- Spec coverage: all issue acceptance criteria map to Shared contracts, Telegram adapter, handler source tests, and build/test verification.
|
|
||||||
- Placeholder scan: no `TBD`, `TODO`, or "fill later" placeholders are left in this plan.
|
|
||||||
- Type consistency: all snippets use `GmRelay.Shared.Platform`, `PlatformKind.Telegram`, `PlatformMessageRef`, and `IPlatformMessenger` consistently.
|
|
||||||
- Scope control: inbound Telegram update parsing remains out of scope; outbound schedule/private/interaction/calendar operations are in scope.
|
|
||||||
@@ -1,731 +0,0 @@
|
|||||||
# Discord NetCord Gateway Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Add a separate `src/GmRelay.DiscordBot` worker that uses NetCord Gateway for Discord slash commands and component interactions while keeping Telegram dependencies isolated in `src/GmRelay.Bot`.
|
|
||||||
|
|
||||||
**Architecture:** Create a new .NET worker project that references `GmRelay.ServiceDefaults` and `GmRelay.Shared`, validates `Discord:Token` during startup, registers NetCord gateway/application command/component services, and logs gateway lifecycle events through NetCord gateway handlers. Keep database connectivity aligned with the existing worker by registering the same `ConnectionStrings:gmrelaydb` `NpgsqlDataSource` pattern, but do not move Telegram code or dependencies.
|
|
||||||
|
|
||||||
**Tech Stack:** .NET 10 worker, Aspire service defaults, NetCord.Hosting `1.0.0-alpha.489`, Npgsql `10.0.2`, xUnit, Docker Compose, Gitea Actions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Issue
|
|
||||||
|
|
||||||
- Gitea issue: `#26`, `feat: добавить src/GmRelay.DiscordBot на NetCord Gateway`
|
|
||||||
- Labels: `type:feature`, `area:discord`, `area:infra`, `platform:discord`, `priority:p1`, `pending-approval`
|
|
||||||
- Version bump: minor, `2.1.1` -> `2.2.0`
|
|
||||||
- Branch: `feature/issue-26-discord-netcord-gateway`
|
|
||||||
|
|
||||||
## Sources Checked
|
|
||||||
|
|
||||||
- NetCord application commands guide: `https://netcord.dev/guides/services/application-commands/introduction.html`
|
|
||||||
- NetCord intents guide: `https://netcord.dev/guides/events/intents.html`
|
|
||||||
- NetCord gateway handler docs: `https://netcord.dev/docs/NetCord.Hosting.Gateway.html`
|
|
||||||
- NuGet flat container for `NetCord.Hosting`: latest observed version `1.0.0-alpha.489`
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
- Create: `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` - Discord worker project and package references.
|
|
||||||
- Create: `src/GmRelay.DiscordBot/Program.cs` - host composition, token validation, database registration, NetCord service registration.
|
|
||||||
- Create: `src/GmRelay.DiscordBot/DiscordOptions.cs` - strongly typed Discord token/options validation.
|
|
||||||
- Create: `src/GmRelay.DiscordBot/Infrastructure/Logging/SecretRedactor.cs` - Discord-local startup redaction without referencing the Telegram worker project.
|
|
||||||
- Create: `src/GmRelay.DiscordBot/Infrastructure/Logging/DiscordGatewayLifecycleLogger.cs` - NetCord gateway lifecycle handler for ready/connect/resume/disconnect/close/rate-limit events where available.
|
|
||||||
- Create: `src/GmRelay.DiscordBot/Dockerfile` - publish and runtime image for the Discord worker.
|
|
||||||
- Modify: `GM-Relay.slnx` - include the new project.
|
|
||||||
- Modify: `src/GmRelay.AppHost/GmRelay.AppHost.csproj` - reference the Discord worker for Aspire orchestration.
|
|
||||||
- Modify: `src/GmRelay.AppHost/Program.cs` - add `discord` project with PostgreSQL reference.
|
|
||||||
- Modify: `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj` - reference the Discord worker project.
|
|
||||||
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs` - source-level tests for solution inclusion, Docker/Compose/CI wiring, and Telegram isolation.
|
|
||||||
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs` - unit tests for token validation.
|
|
||||||
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs` - source-level startup tests for NetCord registration, service defaults, and PostgreSQL connection requirements.
|
|
||||||
- Modify: `compose.yaml` - add `discord` service and versioned image tag.
|
|
||||||
- Modify: `.gitea/workflows/deploy.yml` - build/push/scan/pull Discord image and include `DISCORD_BOT_TOKEN` in `.env`.
|
|
||||||
- Modify: `.gitea/workflows/pr-checks.yml` - build the Discord project in PR checks.
|
|
||||||
- Modify: `Directory.Build.props` - version `2.2.0`.
|
|
||||||
- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor` - visible version `v2.2.0`.
|
|
||||||
- Generated by restore: `src/GmRelay.DiscordBot/packages.lock.json`.
|
|
||||||
- Generated by restore: updates to `tests/GmRelay.Bot.Tests/packages.lock.json` and `src/GmRelay.AppHost/packages.lock.json`.
|
|
||||||
|
|
||||||
## TDD Plan
|
|
||||||
|
|
||||||
### Task 1: Project Presence And Telegram Isolation
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs`
|
|
||||||
- Modify: `GM-Relay.slnx`
|
|
||||||
- Create: `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj`
|
|
||||||
- Create: `src/GmRelay.DiscordBot/Program.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Discord;
|
|
||||||
|
|
||||||
public sealed class DiscordProjectStructureTests
|
|
||||||
{
|
|
||||||
private static string GetRepoRoot()
|
|
||||||
{
|
|
||||||
var dir = AppContext.BaseDirectory;
|
|
||||||
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props")))
|
|
||||||
{
|
|
||||||
dir = Directory.GetParent(dir)?.FullName;
|
|
||||||
}
|
|
||||||
|
|
||||||
return dir ?? throw new InvalidOperationException("Could not find repo root");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Solution_ShouldIncludeDiscordWorkerProject()
|
|
||||||
{
|
|
||||||
var repoRoot = GetRepoRoot();
|
|
||||||
var solution = File.ReadAllText(Path.Combine(repoRoot, "GM-Relay.slnx"));
|
|
||||||
|
|
||||||
Assert.Contains("src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj", solution);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DiscordWorkerProject_ShouldExistWithoutTelegramDependency()
|
|
||||||
{
|
|
||||||
var repoRoot = GetRepoRoot();
|
|
||||||
var projectPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "GmRelay.DiscordBot.csproj");
|
|
||||||
|
|
||||||
Assert.True(File.Exists(projectPath), "Discord worker project should exist.");
|
|
||||||
|
|
||||||
var project = File.ReadAllText(projectPath);
|
|
||||||
Assert.Contains("Microsoft.NET.Sdk.Worker", project);
|
|
||||||
Assert.Contains("NetCord.Hosting", project);
|
|
||||||
Assert.Contains("GmRelay.ServiceDefaults.csproj", project);
|
|
||||||
Assert.Contains("GmRelay.Shared.csproj", project);
|
|
||||||
Assert.DoesNotContain("Telegram.Bot", project);
|
|
||||||
Assert.DoesNotContain("GmRelay.Bot.csproj", project);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void TelegramWorkerProject_ShouldNotReferenceNetCord()
|
|
||||||
{
|
|
||||||
var repoRoot = GetRepoRoot();
|
|
||||||
var project = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Bot", "GmRelay.Bot.csproj"));
|
|
||||||
|
|
||||||
Assert.DoesNotContain("NetCord", project, StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the test to verify it fails**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordProjectStructureTests`
|
|
||||||
|
|
||||||
Expected: FAIL because `GM-Relay.slnx` does not include `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` and the project file does not exist.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Write minimal implementation**
|
|
||||||
|
|
||||||
Create `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj`:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<LangVersion>preview</LangVersion>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Aspire.Npgsql" Version="13.2.2" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
|
|
||||||
<PackageReference Include="NetCord.Hosting" Version="1.0.0-alpha.489" />
|
|
||||||
<PackageReference Include="Npgsql" Version="10.0.2" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\GmRelay.ServiceDefaults\GmRelay.ServiceDefaults.csproj" />
|
|
||||||
<ProjectReference Include="..\GmRelay.Shared\GmRelay.Shared.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
```
|
|
||||||
|
|
||||||
Add this project to `GM-Relay.slnx` inside `/src/`:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<Project Path="src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj" />
|
|
||||||
```
|
|
||||||
|
|
||||||
Create temporary minimal `src/GmRelay.DiscordBot/Program.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
var builder = Host.CreateApplicationBuilder(args);
|
|
||||||
builder.AddServiceDefaults();
|
|
||||||
await builder.Build().RunAsync();
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the test to verify it passes**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordProjectStructureTests`
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
### Task 2: Token Validation
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj`
|
|
||||||
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs`
|
|
||||||
- Create: `src/GmRelay.DiscordBot/DiscordOptions.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
Add the project reference to `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj`:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<ProjectReference Include="..\..\src\GmRelay.DiscordBot\GmRelay.DiscordBot.csproj" />
|
|
||||||
```
|
|
||||||
|
|
||||||
Create `tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using GmRelay.DiscordBot;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Discord;
|
|
||||||
|
|
||||||
public sealed class DiscordOptionsTests
|
|
||||||
{
|
|
||||||
[Theory]
|
|
||||||
[InlineData(null)]
|
|
||||||
[InlineData("")]
|
|
||||||
[InlineData(" ")]
|
|
||||||
public void Validate_ShouldRejectMissingToken(string? token)
|
|
||||||
{
|
|
||||||
var options = new DiscordOptions { Token = token };
|
|
||||||
|
|
||||||
var exception = Assert.Throws<InvalidOperationException>(options.Validate);
|
|
||||||
|
|
||||||
Assert.Contains("Discord:Token is required", exception.Message);
|
|
||||||
Assert.Contains("Discord__Token", exception.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Validate_ShouldAcceptConfiguredToken()
|
|
||||||
{
|
|
||||||
var options = new DiscordOptions { Token = "configured-token" };
|
|
||||||
|
|
||||||
options.Validate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the test to verify it fails**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordOptionsTests`
|
|
||||||
|
|
||||||
Expected: FAIL at compile time because `GmRelay.DiscordBot.DiscordOptions` is not defined.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Write minimal implementation**
|
|
||||||
|
|
||||||
Create `src/GmRelay.DiscordBot/DiscordOptions.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
namespace GmRelay.DiscordBot;
|
|
||||||
|
|
||||||
public sealed class DiscordOptions
|
|
||||||
{
|
|
||||||
public string? Token { get; init; }
|
|
||||||
|
|
||||||
public void Validate()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(Token))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Discord:Token is required. Set via environment variable Discord__Token or user secrets.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the test to verify it passes**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordOptionsTests`
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
### Task 3: Startup Wiring For Service Defaults, PostgreSQL, NetCord, And Slash Commands
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs`
|
|
||||||
- Modify: `src/GmRelay.DiscordBot/Program.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
Create `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Discord;
|
|
||||||
|
|
||||||
public sealed class DiscordStartupTests
|
|
||||||
{
|
|
||||||
private static string GetRepoRoot()
|
|
||||||
{
|
|
||||||
var dir = AppContext.BaseDirectory;
|
|
||||||
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props")))
|
|
||||||
{
|
|
||||||
dir = Directory.GetParent(dir)?.FullName;
|
|
||||||
}
|
|
||||||
|
|
||||||
return dir ?? throw new InvalidOperationException("Could not find repo root");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Program_ShouldValidateDiscordTokenBeforeRunning()
|
|
||||||
{
|
|
||||||
var program = ReadProgram();
|
|
||||||
|
|
||||||
Assert.Contains("GetRequiredSection(\"Discord\")", program);
|
|
||||||
Assert.Contains("DiscordOptions", program);
|
|
||||||
Assert.Contains(".Validate()", program);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Program_ShouldRegisterServiceDefaultsAndPostgresDataSource()
|
|
||||||
{
|
|
||||||
var program = ReadProgram();
|
|
||||||
|
|
||||||
Assert.Contains("builder.AddServiceDefaults()", program);
|
|
||||||
Assert.Contains("ConnectionStrings:gmrelaydb is required", program);
|
|
||||||
Assert.Contains("NpgsqlDataSource", program);
|
|
||||||
Assert.Contains("SecretRedactor.RedactConnectionString", program);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Program_ShouldRegisterNetCordGatewayApplicationCommandsAndComponents()
|
|
||||||
{
|
|
||||||
var program = ReadProgram();
|
|
||||||
|
|
||||||
Assert.Contains(".AddDiscordGateway", program);
|
|
||||||
Assert.Contains(".AddApplicationCommands", program);
|
|
||||||
Assert.Contains(".AddComponentInteractions", program);
|
|
||||||
Assert.Contains(".AddGatewayHandlers", program);
|
|
||||||
Assert.Contains("AddSlashCommand", program);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ReadProgram()
|
|
||||||
{
|
|
||||||
var repoRoot = GetRepoRoot();
|
|
||||||
return File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Program.cs"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the test to verify it fails**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordStartupTests`
|
|
||||||
|
|
||||||
Expected: FAIL because `Program.cs` does not validate `Discord:Token`, register `NpgsqlDataSource`, or register NetCord services yet.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Write minimal implementation**
|
|
||||||
|
|
||||||
Replace `src/GmRelay.DiscordBot/Program.cs` with host composition that:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using GmRelay.DiscordBot;
|
|
||||||
using GmRelay.DiscordBot.Infrastructure.Logging;
|
|
||||||
using NetCord.Gateway;
|
|
||||||
using NetCord.Hosting.Gateway;
|
|
||||||
using NetCord.Hosting.Services.ApplicationCommands;
|
|
||||||
using NetCord.Hosting.Services.ComponentInteractions;
|
|
||||||
using Npgsql;
|
|
||||||
|
|
||||||
var builder = Host.CreateApplicationBuilder(args);
|
|
||||||
|
|
||||||
builder.AddServiceDefaults();
|
|
||||||
|
|
||||||
var discordOptions = builder.Configuration
|
|
||||||
.GetRequiredSection("Discord")
|
|
||||||
.Get<DiscordOptions>() ?? new DiscordOptions();
|
|
||||||
discordOptions.Validate();
|
|
||||||
|
|
||||||
builder.Services.AddSingleton(discordOptions);
|
|
||||||
|
|
||||||
builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
|
|
||||||
{
|
|
||||||
var config = sp.GetRequiredService<IConfiguration>();
|
|
||||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
|
||||||
var connectionString = config.GetConnectionString("gmrelaydb")
|
|
||||||
?? throw new InvalidOperationException(
|
|
||||||
"ConnectionStrings:gmrelaydb is required. Set via environment variable ConnectionStrings__gmrelaydb.");
|
|
||||||
|
|
||||||
var logger = loggerFactory.CreateLogger("GmRelay.DiscordBot.Startup");
|
|
||||||
logger.LogInformation(
|
|
||||||
"Configured PostgreSQL data source with connection string {ConnectionString}",
|
|
||||||
SecretRedactor.RedactConnectionString(connectionString));
|
|
||||||
|
|
||||||
return NpgsqlDataSource.Create(connectionString);
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services
|
|
||||||
.AddDiscordGateway(options =>
|
|
||||||
{
|
|
||||||
options.Token = discordOptions.Token;
|
|
||||||
options.Intents = GatewayIntents.Guilds;
|
|
||||||
})
|
|
||||||
.AddApplicationCommands()
|
|
||||||
.AddComponentInteractions()
|
|
||||||
.AddGatewayHandlers(typeof(Program).Assembly);
|
|
||||||
|
|
||||||
var host = builder.Build();
|
|
||||||
|
|
||||||
host.AddSlashCommand("ping", "Checks whether GM-Relay Discord is online.", () => "Pong!");
|
|
||||||
|
|
||||||
await host.RunAsync();
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the Discord-local `SecretRedactor` namespace instead of `GmRelay.Bot.Infrastructure.Logging` so the new project does not reference the Telegram worker.
|
|
||||||
|
|
||||||
Create `src/GmRelay.DiscordBot/Infrastructure/Logging/SecretRedactor.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
|
|
||||||
namespace GmRelay.DiscordBot.Infrastructure.Logging;
|
|
||||||
|
|
||||||
internal static partial class SecretRedactor
|
|
||||||
{
|
|
||||||
public static string RedactConnectionString(string connectionString)
|
|
||||||
{
|
|
||||||
return PasswordPattern().Replace(connectionString, "$1***");
|
|
||||||
}
|
|
||||||
|
|
||||||
[GeneratedRegex(@"(?i)(Password\s*=\s*)[^;]+")]
|
|
||||||
private static partial Regex PasswordPattern();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If `GatewayClientOptions.Token` does not accept `string`, adjust to NetCord's required token type after compile feedback while preserving the tests' intent.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the test to verify it passes**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordStartupTests`
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
### Task 4: Gateway Lifecycle Logging
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs`
|
|
||||||
- Create: `src/GmRelay.DiscordBot/Infrastructure/Logging/DiscordGatewayLifecycleLogger.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
Add to `DiscordStartupTests.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[Fact]
|
|
||||||
public void LifecycleLogger_ShouldLogGatewayLifecycleEventsWithoutTokenValues()
|
|
||||||
{
|
|
||||||
var repoRoot = GetRepoRoot();
|
|
||||||
var loggerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Infrastructure", "Logging", "DiscordGatewayLifecycleLogger.cs");
|
|
||||||
|
|
||||||
Assert.True(File.Exists(loggerPath), "Discord gateway lifecycle logger should exist.");
|
|
||||||
|
|
||||||
var logger = File.ReadAllText(loggerPath);
|
|
||||||
Assert.Contains("IReadyGatewayHandler", logger);
|
|
||||||
Assert.Contains("IDisconnectGatewayHandler", logger);
|
|
||||||
Assert.Contains("IResumeGatewayHandler", logger);
|
|
||||||
Assert.Contains("LogInformation", logger);
|
|
||||||
Assert.DoesNotContain("Token", logger);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the test to verify it fails**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter LifecycleLogger_ShouldLogGatewayLifecycleEventsWithoutTokenValues`
|
|
||||||
|
|
||||||
Expected: FAIL because `DiscordGatewayLifecycleLogger.cs` does not exist.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Write minimal implementation**
|
|
||||||
|
|
||||||
Create `src/GmRelay.DiscordBot/Infrastructure/Logging/DiscordGatewayLifecycleLogger.cs` using the concrete NetCord handler signatures from the installed `NetCord.Hosting` package. Minimum behavior:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using NetCord.Gateway;
|
|
||||||
using NetCord.Hosting.Gateway;
|
|
||||||
|
|
||||||
namespace GmRelay.DiscordBot.Infrastructure.Logging;
|
|
||||||
|
|
||||||
public sealed class DiscordGatewayLifecycleLogger(
|
|
||||||
ILogger<DiscordGatewayLifecycleLogger> logger)
|
|
||||||
: IReadyGatewayHandler,
|
|
||||||
IDisconnectGatewayHandler,
|
|
||||||
IResumeGatewayHandler
|
|
||||||
{
|
|
||||||
public ValueTask HandleAsync(ReadyEventArgs arg)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Discord gateway ready as application {ApplicationId}", arg.Application.Id);
|
|
||||||
return ValueTask.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ValueTask HandleAsync(DisconnectEventArgs arg)
|
|
||||||
{
|
|
||||||
logger.LogWarning("Discord gateway disconnected with close status {CloseStatus}", arg.CloseStatus);
|
|
||||||
return ValueTask.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ValueTask HandleAsync()
|
|
||||||
{
|
|
||||||
logger.LogInformation("Discord gateway session resumed");
|
|
||||||
return ValueTask.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If interface signatures differ in `1.0.0-alpha.489`, inspect the package XML/docs and adjust the handlers to compile while keeping ready/disconnect/resume logging and never logging token values.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the test to verify it passes**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter LifecycleLogger_ShouldLogGatewayLifecycleEventsWithoutTokenValues`
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
### Task 5: Runtime Container, Compose, AppHost, And CI Wiring
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs`
|
|
||||||
- Create: `src/GmRelay.DiscordBot/Dockerfile`
|
|
||||||
- Modify: `compose.yaml`
|
|
||||||
- Modify: `src/GmRelay.AppHost/GmRelay.AppHost.csproj`
|
|
||||||
- Modify: `src/GmRelay.AppHost/Program.cs`
|
|
||||||
- Modify: `.gitea/workflows/pr-checks.yml`
|
|
||||||
- Modify: `.gitea/workflows/deploy.yml`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
Add to `DiscordProjectStructureTests.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[Fact]
|
|
||||||
public void RuntimeWiring_ShouldIncludeDiscordServiceWithoutCouplingTelegram()
|
|
||||||
{
|
|
||||||
var repoRoot = GetRepoRoot();
|
|
||||||
var compose = File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"));
|
|
||||||
var appHostProject = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "GmRelay.AppHost.csproj"));
|
|
||||||
var appHostProgram = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "Program.cs"));
|
|
||||||
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
|
|
||||||
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
|
|
||||||
|
|
||||||
Assert.Contains("gmrelay-discord-bot:2.2.0", compose);
|
|
||||||
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
|
|
||||||
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
|
|
||||||
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
|
|
||||||
Assert.Contains("dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore", prChecks);
|
|
||||||
Assert.Contains("GmRelay.DiscordBot.csproj", appHostProject);
|
|
||||||
Assert.Contains("Projects.GmRelay_DiscordBot", appHostProgram);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the test to verify it fails**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter RuntimeWiring_ShouldIncludeDiscordServiceWithoutCouplingTelegram`
|
|
||||||
|
|
||||||
Expected: FAIL because runtime wiring is not present.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Write minimal implementation**
|
|
||||||
|
|
||||||
Create `src/GmRelay.DiscordBot/Dockerfile` modeled after `src/GmRelay.Bot/Dockerfile`, with project copy/restore for `GmRelay.DiscordBot`, `GmRelay.ServiceDefaults`, and `GmRelay.Shared`, and entrypoint `./GmRelay.DiscordBot`.
|
|
||||||
|
|
||||||
Add `discord` service to `compose.yaml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
discord:
|
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.2.0
|
|
||||||
restart: always
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
|
|
||||||
- "Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}"
|
|
||||||
networks:
|
|
||||||
- gmrelay
|
|
||||||
```
|
|
||||||
|
|
||||||
Add Discord project reference to `src/GmRelay.AppHost/GmRelay.AppHost.csproj`:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<ProjectReference Include="..\GmRelay.DiscordBot\GmRelay.DiscordBot.csproj" />
|
|
||||||
```
|
|
||||||
|
|
||||||
Add Discord service to `src/GmRelay.AppHost/Program.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
builder.AddProject<Projects.GmRelay_DiscordBot>("discord")
|
|
||||||
.WithReference(postgres)
|
|
||||||
.WaitFor(postgres);
|
|
||||||
```
|
|
||||||
|
|
||||||
Update `.gitea/workflows/pr-checks.yml` with:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Build Discord Bot (compile check, includes SAST)
|
|
||||||
run: dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore
|
|
||||||
```
|
|
||||||
|
|
||||||
Update `.gitea/workflows/deploy.yml` to build, push, scan, pull, and deploy `git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }}` and write `DISCORD_BOT_TOKEN=${{ secrets.DISCORD_BOT_TOKEN }}` to `.env`.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the test to verify it passes**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter RuntimeWiring_ShouldIncludeDiscordServiceWithoutCouplingTelegram`
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
### Task 6: Version Synchronization
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs`
|
|
||||||
- Modify: `Directory.Build.props`
|
|
||||||
- Modify: `compose.yaml`
|
|
||||||
- Modify: `.gitea/workflows/deploy.yml`
|
|
||||||
- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
Add to `DiscordProjectStructureTests.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[Fact]
|
|
||||||
public void Version_ShouldBeSynchronizedForDiscordFeatureRelease()
|
|
||||||
{
|
|
||||||
var repoRoot = GetRepoRoot();
|
|
||||||
|
|
||||||
Assert.Contains("<Version>2.2.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
|
||||||
Assert.Contains("VERSION: 2.2.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
|
||||||
Assert.Contains("gmrelay-bot:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
|
||||||
Assert.Contains("gmrelay-web:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
|
||||||
Assert.Contains("gmrelay-discord-bot:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
|
||||||
Assert.Contains("v2.2.0", File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the test to verify it fails**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter Version_ShouldBeSynchronizedForDiscordFeatureRelease`
|
|
||||||
|
|
||||||
Expected: FAIL because current version is `2.1.1`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Write minimal implementation**
|
|
||||||
|
|
||||||
Update:
|
|
||||||
- `Directory.Build.props`: `<Version>2.2.0</Version>`
|
|
||||||
- `.gitea/workflows/deploy.yml`: `VERSION: 2.2.0`
|
|
||||||
- `compose.yaml`: `gmrelay-bot:2.2.0`, `gmrelay-web:2.2.0`, `gmrelay-discord-bot:2.2.0`
|
|
||||||
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`: `v2.2.0`
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the test to verify it passes**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter Version_ShouldBeSynchronizedForDiscordFeatureRelease`
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
### Task 7: Restore, Format, Build, And Full Test Verification
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Generated/updated: `src/GmRelay.DiscordBot/packages.lock.json`
|
|
||||||
- Generated/updated: `tests/GmRelay.Bot.Tests/packages.lock.json`
|
|
||||||
- Generated/updated: `src/GmRelay.AppHost/packages.lock.json`
|
|
||||||
- Any code formatting changes required by `dotnet format`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Restore lock files**
|
|
||||||
|
|
||||||
Run: `dotnet restore GM-Relay.slnx`
|
|
||||||
|
|
||||||
Expected: restore succeeds and creates/updates lock files for the new project references and NetCord dependency.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run targeted tests**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~Discord`
|
|
||||||
|
|
||||||
Expected: all Discord tests pass.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run full tests**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal`
|
|
||||||
|
|
||||||
Expected: all tests pass.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run release build**
|
|
||||||
|
|
||||||
Run: `dotnet build GM-Relay.slnx -c Release`
|
|
||||||
|
|
||||||
Expected: solution build succeeds and includes `src/GmRelay.DiscordBot`.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run format check**
|
|
||||||
|
|
||||||
Run: `dotnet format --verify-no-changes --verbosity diagnostic`
|
|
||||||
|
|
||||||
Expected: no formatting changes required.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Inspect diff for secrets**
|
|
||||||
|
|
||||||
Run: `git diff --check`
|
|
||||||
|
|
||||||
Expected: no whitespace errors and no Discord token value in tracked files.
|
|
||||||
|
|
||||||
Run: `git diff -- . ':!*.lock.json'`
|
|
||||||
|
|
||||||
Expected: diff contains configuration variable names such as `Discord__Token` and `DISCORD_BOT_TOKEN`, but not a real token value.
|
|
||||||
|
|
||||||
### Task 8: Commit, PR, CI, Deploy, Release, Issue Closure
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- All intended implementation, test, lock, workflow, compose, and version files.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create commit**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git status --short
|
|
||||||
git add GM-Relay.slnx Directory.Build.props compose.yaml .gitea/workflows/deploy.yml .gitea/workflows/pr-checks.yml src/GmRelay.AppHost src/GmRelay.DiscordBot src/GmRelay.Web/Components/Layout/NavMenu.razor tests/GmRelay.Bot.Tests
|
|
||||||
git commit -m "feat: add Discord NetCord gateway worker"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: only intended files are staged and committed. Do not stage untracked `CLAUDE.md`.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Push branch and open PR**
|
|
||||||
|
|
||||||
Run: `git push -u origin feature/issue-26-discord-netcord-gateway`
|
|
||||||
|
|
||||||
Create Gitea PR to `main` with:
|
|
||||||
- Summary of Discord worker, token validation, runtime wiring, and version bump.
|
|
||||||
- Test plan showing targeted Discord tests, full tests, release build, format, and secret diff inspection.
|
|
||||||
- Link to issue `#26`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Store Discord token as a Gitea Actions secret**
|
|
||||||
|
|
||||||
Use Gitea Actions configuration to create or update repository secret `DISCORD_BOT_TOKEN` with the user-provided Discord bot token.
|
|
||||||
|
|
||||||
Expected: token is stored only as an Actions secret. The token value is not written to source files, plan files, logs, PR text, release notes, or commits.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Monitor CI**
|
|
||||||
|
|
||||||
Use Gitea Actions run reads until PR checks finish. If CI fails, inspect logs, fix with TDD where the failure is code behavior, push again, and re-check.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Review, merge, deploy, release**
|
|
||||||
|
|
||||||
After CI passes and review is approved:
|
|
||||||
- Merge PR.
|
|
||||||
- Monitor deploy workflow on `main`.
|
|
||||||
- Create release `v2.2.0` with Russian release notes.
|
|
||||||
- Close issue `#26` with a comment linking PR and release.
|
|
||||||
|
|
||||||
## Self-Review
|
|
||||||
|
|
||||||
- Spec coverage: Project creation, NetCord Gateway, slash/component service registration, `Discord__Token`, PostgreSQL service defaults, lifecycle logging, Telegram isolation, solution build, compose/deploy integration, and version sync are covered.
|
|
||||||
- Placeholder scan: No task uses `TBD`, `TODO`, or an unspecified "add tests" instruction.
|
|
||||||
- Type consistency: Test class names and file paths are consistent across tasks; NetCord lifecycle handler signatures are explicitly marked for compile-driven adjustment because the package is prerelease and must be verified against installed `1.0.0-alpha.489`.
|
|
||||||
@@ -1,599 +0,0 @@
|
|||||||
# Platform-Neutral Join Leave Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Implement Gitea issue #25 by making join/leave session interactions use platform-neutral command models while preserving Telegram callback behavior, seat limits, and waitlist semantics.
|
|
||||||
|
|
||||||
**Architecture:** Telegram callback routing remains in `UpdateRouter`, but it becomes an adapter that converts callback data into `PlatformUser`, `PlatformGroup`, and `PlatformMessageRef` values. `JoinSessionHandler` and `LeaveSessionHandler` operate on those neutral values, persist players by `(platform, external_user_id)`, and update schedules through `IPlatformMessenger`.
|
|
||||||
|
|
||||||
**Tech Stack:** .NET 10, xUnit, Dapper, Npgsql, Gitea Actions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Issue Context
|
|
||||||
|
|
||||||
- Issue: `#25 refactor: obobshchit JoinSession i LeaveSession pod platform-neutral interactions`
|
|
||||||
- Labels: `area:bot`, `area:platform`, `area:shared`, `platform:multi`, `type:refactor`
|
|
||||||
- Version bump: patch, `2.1.0` -> `2.1.1`. The issue is labeled refactor, not breaking; do not use a major bump without explicit approval.
|
|
||||||
- Existing untracked file: `CLAUDE.md`; do not stage or modify it.
|
|
||||||
|
|
||||||
## File Map
|
|
||||||
|
|
||||||
- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs`
|
|
||||||
- Reflection tests proving join/leave command records expose neutral properties and no Telegram-specific identity/message fields.
|
|
||||||
- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs`
|
|
||||||
- Source-level regression tests for handler SQL and messenger boundaries.
|
|
||||||
- Modify: `tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs`
|
|
||||||
- Add a migration test for nullable legacy `players.telegram_id`, required for non-Telegram player inserts.
|
|
||||||
- Create: `src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql`
|
|
||||||
- Drop `NOT NULL` from legacy Telegram-only player columns.
|
|
||||||
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs`
|
|
||||||
- Change `JoinSessionCommand` to neutral properties and query/upsert players by platform identity.
|
|
||||||
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs`
|
|
||||||
- Change `LeaveSessionCommand` to neutral properties and find participants by platform identity.
|
|
||||||
- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs`
|
|
||||||
- Convert Telegram callback data into neutral command values using `TelegramPlatformIds`.
|
|
||||||
- Modify: version files after implementation:
|
|
||||||
- `Directory.Build.props`
|
|
||||||
- `compose.yaml`
|
|
||||||
- `.gitea/workflows/deploy.yml`
|
|
||||||
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
|
|
||||||
|
|
||||||
## Task 1: RED - Command Model Tests
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing command-shape tests**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
|
||||||
using GmRelay.Shared.Platform;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
|
|
||||||
|
|
||||||
public sealed class PlatformNeutralSessionInteractionCommandTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void JoinSessionCommand_ShouldExposePlatformNeutralInteractionContext()
|
|
||||||
{
|
|
||||||
AssertProperty<JoinSessionCommand>("SessionId", typeof(Guid));
|
|
||||||
AssertProperty<JoinSessionCommand>("User", typeof(PlatformUser));
|
|
||||||
AssertProperty<JoinSessionCommand>("InteractionId", typeof(string));
|
|
||||||
AssertProperty<JoinSessionCommand>("Group", typeof(PlatformGroup));
|
|
||||||
AssertProperty<JoinSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
|
|
||||||
AssertNoTelegramSpecificProperties<JoinSessionCommand>();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void LeaveSessionCommand_ShouldExposePlatformNeutralInteractionContext()
|
|
||||||
{
|
|
||||||
AssertProperty<LeaveSessionCommand>("SessionId", typeof(Guid));
|
|
||||||
AssertProperty<LeaveSessionCommand>("User", typeof(PlatformUser));
|
|
||||||
AssertProperty<LeaveSessionCommand>("InteractionId", typeof(string));
|
|
||||||
AssertProperty<LeaveSessionCommand>("Group", typeof(PlatformGroup));
|
|
||||||
AssertProperty<LeaveSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
|
|
||||||
AssertNoTelegramSpecificProperties<LeaveSessionCommand>();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AssertProperty<T>(string name, Type expectedType)
|
|
||||||
{
|
|
||||||
var property = Assert.Single(typeof(T).GetProperties(), property => property.Name == name);
|
|
||||||
|
|
||||||
Assert.Equal(expectedType, property.PropertyType);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AssertNoTelegramSpecificProperties<T>()
|
|
||||||
{
|
|
||||||
var names = typeof(T).GetProperties().Select(property => property.Name).ToArray();
|
|
||||||
|
|
||||||
Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal));
|
|
||||||
Assert.DoesNotContain("ChatId", names);
|
|
||||||
Assert.DoesNotContain("MessageId", names);
|
|
||||||
Assert.DoesNotContain("TelegramUserId", names);
|
|
||||||
Assert.DoesNotContain("TelegramUsername", names);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify RED**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter PlatformNeutralSessionInteractionCommandTests
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: FAIL because `JoinSessionCommand` and `LeaveSessionCommand` still expose `TelegramUserId`, `ChatId`, and `MessageId`, and do not expose `User`, `Group`, or `ScheduleMessage`.
|
|
||||||
|
|
||||||
## Task 2: RED - SQL and Boundary Tests
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs`
|
|
||||||
- Modify: `tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing handler source tests**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
|
|
||||||
|
|
||||||
public sealed class PlatformNeutralSessionInteractionSqlTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task JoinSessionHandler_ShouldPersistPlayersByPlatformIdentity()
|
|
||||||
{
|
|
||||||
var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs");
|
|
||||||
|
|
||||||
Assert.Contains("platform, external_user_id", handler, StringComparison.Ordinal);
|
|
||||||
Assert.Contains("ON CONFLICT (platform, external_user_id)", handler, StringComparison.Ordinal);
|
|
||||||
Assert.Contains("ExternalUserId", handler, StringComparison.Ordinal);
|
|
||||||
Assert.Contains("ExternalUsername", handler, StringComparison.Ordinal);
|
|
||||||
Assert.DoesNotContain("TelegramPlatformIds.", handler, StringComparison.Ordinal);
|
|
||||||
Assert.DoesNotContain("command.TelegramUserId", handler, StringComparison.Ordinal);
|
|
||||||
Assert.DoesNotContain("command.TelegramUsername", handler, StringComparison.Ordinal);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task LeaveSessionHandler_ShouldFindParticipantsByPlatformIdentity()
|
|
||||||
{
|
|
||||||
var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs");
|
|
||||||
|
|
||||||
Assert.Contains("p.platform = @Platform", handler, StringComparison.Ordinal);
|
|
||||||
Assert.Contains("p.external_user_id = @ExternalUserId", handler, StringComparison.Ordinal);
|
|
||||||
Assert.DoesNotContain("p.telegram_id = @TelegramUserId", handler, StringComparison.Ordinal);
|
|
||||||
Assert.DoesNotContain("TelegramPlatformIds.", handler, StringComparison.Ordinal);
|
|
||||||
Assert.DoesNotContain("command.TelegramUserId", handler, StringComparison.Ordinal);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task SessionInteractionHandlers_ShouldUpdateSchedulesThroughCommandMessageReference()
|
|
||||||
{
|
|
||||||
var joinHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs");
|
|
||||||
var leaveHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs");
|
|
||||||
|
|
||||||
Assert.Contains("new PlatformScheduleMessage(", joinHandler, StringComparison.Ordinal);
|
|
||||||
Assert.Contains("command.Group", joinHandler, StringComparison.Ordinal);
|
|
||||||
Assert.Contains("command.ScheduleMessage", joinHandler, StringComparison.Ordinal);
|
|
||||||
Assert.Contains("new PlatformScheduleMessage(", leaveHandler, StringComparison.Ordinal);
|
|
||||||
Assert.Contains("command.Group", leaveHandler, StringComparison.Ordinal);
|
|
||||||
Assert.Contains("command.ScheduleMessage", leaveHandler, StringComparison.Ordinal);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
|
||||||
{
|
|
||||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
|
||||||
while (directory is not null)
|
|
||||||
{
|
|
||||||
var candidate = Path.Combine(directory.FullName, relativePath);
|
|
||||||
if (File.Exists(candidate))
|
|
||||||
{
|
|
||||||
return await File.ReadAllTextAsync(candidate);
|
|
||||||
}
|
|
||||||
|
|
||||||
directory = directory.Parent;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add failing migration assertion**
|
|
||||||
|
|
||||||
Append to `PlatformIdentityMigrationTests`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[Fact]
|
|
||||||
public async Task MigrationV017_ShouldAllowPlayersWithoutLegacyTelegramId()
|
|
||||||
{
|
|
||||||
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql");
|
|
||||||
|
|
||||||
Assert.Contains("ALTER TABLE players", migration, StringComparison.Ordinal);
|
|
||||||
Assert.Contains("telegram_id DROP NOT NULL", migration, StringComparison.Ordinal);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Verify RED**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "PlatformNeutralSessionInteractionSqlTests|MigrationV017_ShouldAllowPlayersWithoutLegacyTelegramId"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: FAIL because handlers still use Telegram-specific properties and the V017 migration file does not exist.
|
|
||||||
|
|
||||||
## Task 3: GREEN - Add Migration
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create the migration**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- =============================================================
|
|
||||||
-- V017: Allow platform-neutral players
|
|
||||||
-- =============================================================
|
|
||||||
-- Legacy Telegram identity columns remain for backward compatibility,
|
|
||||||
-- but non-Telegram platform users do not have Telegram ids.
|
|
||||||
-- =============================================================
|
|
||||||
|
|
||||||
ALTER TABLE players
|
|
||||||
ALTER COLUMN telegram_id DROP NOT NULL;
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify migration test turns green**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter MigrationV017_ShouldAllowPlayersWithoutLegacyTelegramId
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
## Task 4: GREEN - Refactor JoinSessionCommand and Handler
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Replace command record**
|
|
||||||
|
|
||||||
Replace the existing `JoinSessionCommand` declaration with:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public sealed record JoinSessionCommand(
|
|
||||||
Guid SessionId,
|
|
||||||
PlatformUser User,
|
|
||||||
string InteractionId,
|
|
||||||
PlatformGroup Group,
|
|
||||||
PlatformMessageRef ScheduleMessage);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Replace player upsert**
|
|
||||||
|
|
||||||
Use platform identity parameters:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
var platform = command.User.Platform.ToString();
|
|
||||||
var legacyTelegramId = command.User.Platform == PlatformKind.Telegram
|
|
||||||
? long.Parse(command.User.ExternalUserId, CultureInfo.InvariantCulture)
|
|
||||||
: (long?)null;
|
|
||||||
var legacyTelegramUsername = command.User.Platform == PlatformKind.Telegram
|
|
||||||
? command.User.ExternalUsername
|
|
||||||
: null;
|
|
||||||
|
|
||||||
var playerId = await connection.ExecuteScalarAsync<Guid>(
|
|
||||||
@"INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
|
|
||||||
VALUES (@LegacyTelegramId, @Name, @LegacyTelegramUsername, @Platform, @ExternalUserId, @ExternalUsername)
|
|
||||||
ON CONFLICT (platform, external_user_id)
|
|
||||||
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
|
|
||||||
DO UPDATE
|
|
||||||
SET display_name = EXCLUDED.display_name,
|
|
||||||
telegram_username = COALESCE(EXCLUDED.telegram_username, players.telegram_username),
|
|
||||||
platform = EXCLUDED.platform,
|
|
||||||
external_user_id = EXCLUDED.external_user_id,
|
|
||||||
external_username = EXCLUDED.external_username
|
|
||||||
RETURNING id;",
|
|
||||||
new
|
|
||||||
{
|
|
||||||
LegacyTelegramId = legacyTelegramId,
|
|
||||||
Name = command.User.DisplayName,
|
|
||||||
LegacyTelegramUsername = legacyTelegramUsername,
|
|
||||||
Platform = platform,
|
|
||||||
command.User.ExternalUserId,
|
|
||||||
command.User.ExternalUsername
|
|
||||||
},
|
|
||||||
transaction);
|
|
||||||
```
|
|
||||||
|
|
||||||
Add `using System.Globalization;` at the top.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update participant display query**
|
|
||||||
|
|
||||||
Change the participant projection to prefer platform-neutral username:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
COALESCE(p.external_username, p.telegram_username) as TelegramUsername
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Update schedule message and interaction reply usage**
|
|
||||||
|
|
||||||
Use:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
await messenger.UpdateScheduleAsync(
|
|
||||||
new PlatformScheduleMessage(
|
|
||||||
command.Group,
|
|
||||||
view,
|
|
||||||
command.ScheduleMessage),
|
|
||||||
ct);
|
|
||||||
```
|
|
||||||
|
|
||||||
and:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
|
|
||||||
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace all `command.CallbackQueryId` calls with `command.InteractionId`.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Verify command and SQL tests for join**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "JoinSessionCommand_ShouldExposePlatformNeutralInteractionContext|JoinSessionHandler_ShouldPersistPlayersByPlatformIdentity"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS for join-focused tests.
|
|
||||||
|
|
||||||
## Task 5: GREEN - Refactor LeaveSessionCommand and Handler
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Replace command record**
|
|
||||||
|
|
||||||
Replace the existing `LeaveSessionCommand` declaration with:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public sealed record LeaveSessionCommand(
|
|
||||||
Guid SessionId,
|
|
||||||
PlatformUser User,
|
|
||||||
string InteractionId,
|
|
||||||
PlatformGroup Group,
|
|
||||||
PlatformMessageRef ScheduleMessage);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Replace participant lookup**
|
|
||||||
|
|
||||||
Use platform identity instead of Telegram id:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
var platform = command.User.Platform.ToString();
|
|
||||||
|
|
||||||
var participant = await connection.QuerySingleOrDefaultAsync<LeaveSessionParticipantDto>(
|
|
||||||
"""
|
|
||||||
SELECT sp.id AS ParticipantRowId,
|
|
||||||
p.display_name AS DisplayName,
|
|
||||||
sp.registration_status AS RegistrationStatus
|
|
||||||
FROM session_participants sp
|
|
||||||
JOIN players p ON p.id = sp.player_id
|
|
||||||
WHERE sp.session_id = @SessionId
|
|
||||||
AND p.platform = @Platform
|
|
||||||
AND p.external_user_id = @ExternalUserId
|
|
||||||
AND sp.is_gm = false
|
|
||||||
FOR UPDATE OF sp
|
|
||||||
""",
|
|
||||||
new { command.SessionId, Platform = platform, command.User.ExternalUserId },
|
|
||||||
transaction);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update participant display query**
|
|
||||||
|
|
||||||
Change the participant projection to:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Update schedule message and interaction reply usage**
|
|
||||||
|
|
||||||
Use:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
await messenger.UpdateScheduleAsync(
|
|
||||||
new PlatformScheduleMessage(
|
|
||||||
command.Group,
|
|
||||||
view,
|
|
||||||
command.ScheduleMessage),
|
|
||||||
ct);
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace all `command.CallbackQueryId` calls with `command.InteractionId`.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Verify leave tests**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "LeaveSessionCommand_ShouldExposePlatformNeutralInteractionContext|LeaveSessionHandler_ShouldFindParticipantsByPlatformIdentity|SessionInteractionHandlers_ShouldUpdateSchedulesThroughCommandMessageReference"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
## Task 6: GREEN - Convert Telegram Router to Neutral Commands
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add local conversion values in `HandleCallbackQueryAsync`**
|
|
||||||
|
|
||||||
After parsing `action`, add:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
var user = TelegramPlatformIds.User(
|
|
||||||
query.From.Id,
|
|
||||||
query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"),
|
|
||||||
query.From.Username);
|
|
||||||
var group = TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title);
|
|
||||||
var scheduleMessage = TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, message.MessageId);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update join command construction**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
var command = new JoinSessionCommand(
|
|
||||||
SessionId: joinSessionId,
|
|
||||||
User: user,
|
|
||||||
InteractionId: query.Id,
|
|
||||||
Group: group,
|
|
||||||
ScheduleMessage: scheduleMessage);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update leave command construction**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
var command = new LeaveSessionCommand(
|
|
||||||
SessionId: leaveSessionId,
|
|
||||||
User: user,
|
|
||||||
InteractionId: query.Id,
|
|
||||||
Group: group,
|
|
||||||
ScheduleMessage: scheduleMessage);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Verify compile**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter PlatformNeutralSessionInteractionCommandTests
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
## Task 7: REFACTOR - Clean Up and Full Test Pass
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify only files already listed if cleanup is needed.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Remove now-unused Telegram handler imports**
|
|
||||||
|
|
||||||
Check `JoinSessionHandler.cs` and `LeaveSessionHandler.cs` for unused:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using GmRelay.Bot.Infrastructure.Telegram;
|
|
||||||
```
|
|
||||||
|
|
||||||
Remove it from handlers if no longer needed.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run focused tests**
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "PlatformNeutralSessionInteractionCommandTests|PlatformNeutralSessionInteractionSqlTests|PlatformIdentityMigrationTests"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run full test suite**
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
dotnet test .\GM-Relay.slnx
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Build solution**
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
dotnet build .\GM-Relay.slnx
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
## Task 8: Version Bump
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `Directory.Build.props`
|
|
||||||
- Modify: `compose.yaml`
|
|
||||||
- Modify: `.gitea/workflows/deploy.yml`
|
|
||||||
- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update version from `2.1.0` to `2.1.1`**
|
|
||||||
|
|
||||||
Expected exact replacements:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<Version>2.1.1</Version>
|
|
||||||
```
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
VERSION: 2.1.1
|
|
||||||
```
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.1.1
|
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-web:2.1.1
|
|
||||||
```
|
|
||||||
|
|
||||||
```razor
|
|
||||||
<div class="nav-version">v2.1.1</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify synchronized versions**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
rg "<Version>|image: git.codeanddice.ru/toutsu/gmrelay-|VERSION:|nav-version" Directory.Build.props compose.yaml .gitea\workflows\deploy.yml src\GmRelay.Web\Components\Layout\NavMenu.razor
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: all project image/app/deploy UI versions show `2.1.1`.
|
|
||||||
|
|
||||||
## Task 9: PR, CI, Review, Merge, Deploy, Release
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- No additional source changes expected.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create branch after approval**
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git checkout -b refactor/issue-25-platform-neutral-join-leave
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Stage only intended files**
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git add docs/superpowers/plans/2026-05-18-platform-neutral-join-leave.md tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git commit -m "refactor: make session join leave platform-neutral"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Push and create Gitea PR**
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git push -u origin refactor/issue-25-platform-neutral-join-leave
|
|
||||||
```
|
|
||||||
|
|
||||||
PR title:
|
|
||||||
|
|
||||||
```text
|
|
||||||
refactor: make session join leave platform-neutral
|
|
||||||
```
|
|
||||||
|
|
||||||
PR body:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Summary
|
|
||||||
- Closes #25.
|
|
||||||
- Converts join/leave session interaction commands from Telegram-specific fields to platform-neutral `PlatformUser`, `PlatformGroup`, and `PlatformMessageRef`.
|
|
||||||
- Persists and looks up session participants by `(platform, external_user_id)`.
|
|
||||||
- Keeps Telegram callback data and schedule update behavior intact.
|
|
||||||
|
|
||||||
## Test plan
|
|
||||||
- `dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "PlatformNeutralSessionInteractionCommandTests|PlatformNeutralSessionInteractionSqlTests|PlatformIdentityMigrationTests"`
|
|
||||||
- `dotnet test .\GM-Relay.slnx`
|
|
||||||
- `dotnet build .\GM-Relay.slnx`
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
- [ ] CI passes
|
|
||||||
- [ ] Code review approved
|
|
||||||
- [ ] Deployed
|
|
||||||
- [ ] Release published
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Watch CI, request review, merge, deploy, release**
|
|
||||||
|
|
||||||
Use Gitea MCP for PR creation, CI polling, review, merge, deploy monitoring, and release `v2.1.1`. Close issue #25 after release and add a comment linking the PR and release.
|
|
||||||
|
|
||||||
## Self-Review
|
|
||||||
|
|
||||||
- Spec coverage: issue scope is covered by neutral command records, Telegram adapter conversion, platform identity SQL, messenger-based schedule updates, and tests.
|
|
||||||
- Placeholder scan: no `TBD`, `TODO`, or "fill later" steps remain.
|
|
||||||
- Type consistency: commands consistently use `PlatformUser User`, `string InteractionId`, `PlatformGroup Group`, and `PlatformMessageRef ScheduleMessage`.
|
|
||||||
@@ -1,984 +0,0 @@
|
|||||||
# Discord /newsession и /listsessions — Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:test-driven-development (TDD) for every task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Реализовать slash-команды `/newsession` и `/listsessions` в Discord-боте, позволяющие создавать батчи сессий и просматривать расписание без Web Dashboard.
|
|
||||||
|
|
||||||
**Architecture:** Каждая команда — отдельный vertical slice в `GmRelay.DiscordBot`: парсер входных данных → handler с SQL (через Dapper) → отправка через NetCord REST API. Рендеринг переиспользует существующий `DiscordSessionBatchRenderer`. Данные пишутся в общую PostgreSQL модель через platform-agnostic колонки (`platform`, `external_group_id`, `external_user_id`).
|
|
||||||
|
|
||||||
**Tech Stack:** .NET 10, NetCord 1.0.0-alpha.489, NetCord.Hosting.Services, Dapper, Npgsql, xUnit.
|
|
||||||
|
|
||||||
**Version Bump:** minor (2.3.0 → 2.4.0) — новый функционал.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
| File | Responsibility |
|
|
||||||
|------|--------------|
|
|
||||||
| `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs` | Slash-команда `/newsession` с параметрами (title, time, seats, link) |
|
|
||||||
| `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs` | Handler создания batch + sessions в БД, проверка прав |
|
|
||||||
| `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs` | Slash-команда `/listsessions` |
|
|
||||||
| `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs` | Handler запроса активных сессий и публикации embed |
|
|
||||||
| `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs` | Проверка прав пользователя в guild (owner/admin/manager) |
|
|
||||||
| `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs` | Реализация `IPlatformMessenger` для отправки/обновления расписания в Discord |
|
|
||||||
| `tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs` | TDD-тесты создания сессий из Discord |
|
|
||||||
| `tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs` | TDD-тесты вывода расписания |
|
|
||||||
| `tests/GmRelay.Bot.Tests/Discord/DiscordPermissionCheckerTests.cs` | TDD-тесты проверки прав |
|
|
||||||
| `src/GmRelay.DiscordBot/Program.cs` | Регистрация DI: handlers, permission checker, platform messenger |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: DiscordPermissionChecker
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs`
|
|
||||||
- Test: `tests/GmRelay.Bot.Tests/Discord/DiscordPermissionCheckerTests.cs`
|
|
||||||
|
|
||||||
**Context:** Discord использует guild-роли. Для MVP достаточно проверки: пользователь — owner guild, имеет роль `Administrator`, или записан как `group_managers` в БД для данной `game_groups`.
|
|
||||||
|
|
||||||
### Step 1.1: Write the failing test
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Discord;
|
|
||||||
|
|
||||||
public sealed class DiscordPermissionCheckerTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void CanManageSchedule_WhenUserIsGuildOwner_ReturnsTrue()
|
|
||||||
{
|
|
||||||
var checker = new DiscordPermissionChecker();
|
|
||||||
var result = checker.CanManageSchedule(
|
|
||||||
guildOwnerId: 123456789ul,
|
|
||||||
userId: 123456789ul,
|
|
||||||
userRoles: Array.Empty<ulong>(),
|
|
||||||
dbManagerUserIds: Array.Empty<ulong>());
|
|
||||||
|
|
||||||
Assert.True(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void CanManageSchedule_WhenUserHasAdministratorRole_ReturnsTrue()
|
|
||||||
{
|
|
||||||
var checker = new DiscordPermissionChecker();
|
|
||||||
var adminRole = 999ul;
|
|
||||||
var result = checker.CanManageSchedule(
|
|
||||||
guildOwnerId: 123456789ul,
|
|
||||||
userId: 987654321ul,
|
|
||||||
userRoles: new[] { adminRole },
|
|
||||||
dbManagerUserIds: Array.Empty<ulong>());
|
|
||||||
|
|
||||||
Assert.True(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void CanManageSchedule_WhenUserIsDbManager_ReturnsTrue()
|
|
||||||
{
|
|
||||||
var checker = new DiscordPermissionChecker();
|
|
||||||
var managerId = 555ul;
|
|
||||||
var result = checker.CanManageSchedule(
|
|
||||||
guildOwnerId: 123456789ul,
|
|
||||||
userId: managerId,
|
|
||||||
userRoles: Array.Empty<ulong>(),
|
|
||||||
dbManagerUserIds: new[] { managerId });
|
|
||||||
|
|
||||||
Assert.True(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void CanManageSchedule_WhenRegularUser_ReturnsFalse()
|
|
||||||
{
|
|
||||||
var checker = new DiscordPermissionChecker();
|
|
||||||
var result = checker.CanManageSchedule(
|
|
||||||
guildOwnerId: 123456789ul,
|
|
||||||
userId: 111ul,
|
|
||||||
userRoles: Array.Empty<ulong>(),
|
|
||||||
dbManagerUserIds: new[] { 222ul });
|
|
||||||
|
|
||||||
Assert.False(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 1.2: Run test to verify it fails
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPermissionCheckerTests" --verbosity normal`
|
|
||||||
Expected: FAIL — `DiscordPermissionChecker` not found.
|
|
||||||
|
|
||||||
### Step 1.3: Write minimal implementation
|
|
||||||
|
|
||||||
Create `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
namespace GmRelay.DiscordBot.Infrastructure.Discord;
|
|
||||||
|
|
||||||
public sealed class DiscordPermissionChecker
|
|
||||||
{
|
|
||||||
// Discord Administrator permission bitflag
|
|
||||||
private const ulong AdministratorPermission = 0x8;
|
|
||||||
|
|
||||||
public bool CanManageSchedule(
|
|
||||||
ulong guildOwnerId,
|
|
||||||
ulong userId,
|
|
||||||
IEnumerable<ulong> userRoles,
|
|
||||||
IEnumerable<ulong> dbManagerUserIds)
|
|
||||||
{
|
|
||||||
if (userId == guildOwnerId)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
if (dbManagerUserIds.Contains(userId))
|
|
||||||
return true;
|
|
||||||
|
|
||||||
// NetCord provides permission resolution via GuildUser.Permissions;
|
|
||||||
// here we accept pre-resolved flag for simplicity.
|
|
||||||
// Actual command handler will pass resolved permissions.
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool CanManageSchedule(ulong guildOwnerId, ulong userId, IEnumerable<ulong> dbManagerUserIds, ulong resolvedPermissions)
|
|
||||||
{
|
|
||||||
if (userId == guildOwnerId)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
if (dbManagerUserIds.Contains(userId))
|
|
||||||
return true;
|
|
||||||
|
|
||||||
return (resolvedPermissions & AdministratorPermission) == AdministratorPermission;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 1.4: Run test to verify it passes
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPermissionCheckerTests" --verbosity normal`
|
|
||||||
Expected: PASS (4/4).
|
|
||||||
|
|
||||||
### Step 1.5: Commit
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs tests/GmRelay.Bot.Tests/Discord/DiscordPermissionCheckerTests.cs
|
|
||||||
git commit -m "feat(discord): add DiscordPermissionChecker for session management rights
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: DiscordListSessionsHandler + Command
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs`
|
|
||||||
- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs`
|
|
||||||
- Test: `tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs`
|
|
||||||
|
|
||||||
**Context:** Handler должен:
|
|
||||||
1. Найти `game_groups` по `external_group_id` = `guild_id`.
|
|
||||||
2. Выбрать предстоящие сессии (`scheduled_at > NOW()`, `status != Cancelled`).
|
|
||||||
3. Собрать участников.
|
|
||||||
4. Построить view через `SessionBatchViewBuilder`.
|
|
||||||
5. Отрендерить через `DiscordSessionBatchRenderer`.
|
|
||||||
6. Отправить embed + buttons в Discord channel.
|
|
||||||
|
|
||||||
### Step 2.1: Write the failing test
|
|
||||||
|
|
||||||
Create `tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using GmRelay.DiscordBot.Features.Sessions;
|
|
||||||
using GmRelay.Shared.Domain;
|
|
||||||
using GmRelay.Shared.Rendering;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Discord;
|
|
||||||
|
|
||||||
public sealed class DiscordListSessionsHandlerTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void BuildSchedule_WithSessions_ReturnsEmbedsAndButtons()
|
|
||||||
{
|
|
||||||
var sessionId = Guid.NewGuid();
|
|
||||||
var sessions = new[]
|
|
||||||
{
|
|
||||||
new SessionBatchDto(sessionId, DateTime.UtcNow.AddDays(1), SessionStatus.Planned, 4, "https://example.com")
|
|
||||||
};
|
|
||||||
var participants = Array.Empty<ParticipantBatchDto>();
|
|
||||||
|
|
||||||
var view = SessionBatchViewBuilder.Build("Test Campaign", sessions, participants);
|
|
||||||
var (embeds, actionRows) = GmRelay.DiscordBot.Rendering.DiscordSessionBatchRenderer.Render(view);
|
|
||||||
|
|
||||||
Assert.Single(embeds);
|
|
||||||
Assert.Single(actionRows);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void BuildSchedule_WithCancelledSession_SkipsActionRows()
|
|
||||||
{
|
|
||||||
var cancelledSessionId = Guid.NewGuid();
|
|
||||||
var sessions = new[] { new SessionBatchDto(cancelledSessionId, DateTime.UtcNow.AddDays(1), SessionStatus.Cancelled, null, "") };
|
|
||||||
var participants = Array.Empty<ParticipantBatchDto>();
|
|
||||||
|
|
||||||
var view = SessionBatchViewBuilder.Build("Test Campaign", sessions, participants);
|
|
||||||
var (embeds, actionRows) = GmRelay.DiscordBot.Rendering.DiscordSessionBatchRenderer.Render(view);
|
|
||||||
|
|
||||||
Assert.Single(embeds);
|
|
||||||
Assert.Empty(actionRows);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2.2: Run test — verify RED
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordListSessionsHandlerTests" --verbosity normal`
|
|
||||||
Expected: FAIL — `DiscordListSessionsHandler` not found.
|
|
||||||
|
|
||||||
### Step 2.3: Write minimal implementation
|
|
||||||
|
|
||||||
Create `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using Dapper;
|
|
||||||
using GmRelay.Shared.Domain;
|
|
||||||
using GmRelay.Shared.Rendering;
|
|
||||||
using NetCord.Rest;
|
|
||||||
using Npgsql;
|
|
||||||
|
|
||||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
|
||||||
|
|
||||||
internal sealed record DiscordSessionListItemDto(
|
|
||||||
Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers,
|
|
||||||
int PlayerCount, int WaitlistCount);
|
|
||||||
|
|
||||||
public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
|
|
||||||
{
|
|
||||||
public async Task<SessionBatchViewModel?> BuildScheduleAsync(
|
|
||||||
string guildId,
|
|
||||||
string channelId,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
|
||||||
|
|
||||||
var sessions = await connection.QueryAsync<DiscordSessionListItemDto>(
|
|
||||||
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status,
|
|
||||||
s.max_players as MaxPlayers,
|
|
||||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
|
|
||||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount
|
|
||||||
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.platform = 'Discord'
|
|
||||||
AND g.external_group_id = @GuildId
|
|
||||||
AND s.status != @Cancelled
|
|
||||||
AND s.scheduled_at > NOW()
|
|
||||||
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players
|
|
||||||
ORDER BY s.scheduled_at ASC",
|
|
||||||
new
|
|
||||||
{
|
|
||||||
GuildId = guildId,
|
|
||||||
Cancelled = SessionStatus.Cancelled,
|
|
||||||
Active = ParticipantRegistrationStatus.Active,
|
|
||||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
|
||||||
});
|
|
||||||
|
|
||||||
var sessionList = sessions.ToList();
|
|
||||||
if (sessionList.Count == 0)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var sessionIds = sessionList.Select(s => s.Id).ToList();
|
|
||||||
var participants = await connection.QueryAsync<ParticipantBatchDto>(
|
|
||||||
@"SELECT sp.session_id as SessionId,
|
|
||||||
p.display_name as DisplayName,
|
|
||||||
COALESCE(p.external_username, p.telegram_username) as TelegramUsername,
|
|
||||||
sp.registration_status as RegistrationStatus
|
|
||||||
FROM session_participants sp
|
|
||||||
JOIN players p ON p.id = sp.player_id
|
|
||||||
WHERE sp.session_id = ANY(@SessionIds) AND sp.is_gm = false
|
|
||||||
ORDER BY sp.registration_status ASC, sp.created_at ASC",
|
|
||||||
new { SessionIds = sessionIds });
|
|
||||||
|
|
||||||
var firstTitle = sessionList.First().Title;
|
|
||||||
var batchDtos = sessionList.Select(s => new SessionBatchDto(
|
|
||||||
s.Id, s.ScheduledAt, s.Status, s.MaxPlayers, "")).ToList();
|
|
||||||
|
|
||||||
return SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Create `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using NetCord.Rest;
|
|
||||||
using NetCord.Services.ApplicationCommands;
|
|
||||||
|
|
||||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
|
||||||
|
|
||||||
[SlashCommand("listsessions", "Show upcoming game sessions in this server")]
|
|
||||||
public class DiscordListSessionsCommand : SlashCommandModule<SlashCommandContext>
|
|
||||||
{
|
|
||||||
private readonly DiscordListSessionsHandler _handler;
|
|
||||||
|
|
||||||
public DiscordListSessionsCommand(DiscordListSessionsHandler handler)
|
|
||||||
{
|
|
||||||
_handler = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task ExecuteAsync()
|
|
||||||
{
|
|
||||||
var guildId = Context.Guild?.Id.ToString()
|
|
||||||
?? throw new InvalidOperationException("This command can only be used in a guild.");
|
|
||||||
var channelId = Context.Channel.Id.ToString();
|
|
||||||
|
|
||||||
var view = await _handler.BuildScheduleAsync(guildId, channelId, Context.CancellationToken);
|
|
||||||
|
|
||||||
if (view is null)
|
|
||||||
{
|
|
||||||
await Context.Interaction.SendResponseAsync(
|
|
||||||
InteractionCallback.Message("📭 В этом сервере нет предстоящих игр."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var (embeds, actionRows) = Rendering.DiscordSessionBatchRenderer.Render(view);
|
|
||||||
|
|
||||||
await Context.Interaction.SendResponseAsync(
|
|
||||||
InteractionCallback.Message(new InteractionMessageProperties()
|
|
||||||
.WithEmbeds(embeds)
|
|
||||||
.WithComponents(actionRows)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2.4: Run test — verify GREEN
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordListSessionsHandlerTests" --verbosity normal`
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
### Step 2.5: Commit
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs
|
|
||||||
git commit -m "feat(discord): add /listsessions slash command and handler
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: DiscordNewSessionHandler + Command
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs`
|
|
||||||
- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs`
|
|
||||||
- Test: `tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs`
|
|
||||||
|
|
||||||
**Context:** Handler должен:
|
|
||||||
1. Проверить права пользователя (owner/admin/manager).
|
|
||||||
2. Upsert игрока (GM) в `players` с `platform = 'Discord'`.
|
|
||||||
3. Upsert `game_groups` с `platform = 'Discord'`, `external_group_id = guild_id`.
|
|
||||||
4. Создать batch + sessions.
|
|
||||||
5. Отправить rendered schedule в Discord channel.
|
|
||||||
6. Сохранить `platform_messages` reference.
|
|
||||||
|
|
||||||
### Step 3.1: Write the failing test
|
|
||||||
|
|
||||||
Create `tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using GmRelay.DiscordBot.Features.Sessions;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Discord;
|
|
||||||
|
|
||||||
public sealed class DiscordNewSessionHandlerTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void ParseTimeInput_ShouldParseDiscordDateFormat()
|
|
||||||
{
|
|
||||||
var result = DiscordNewSessionHandler.ParseTimeInput("2026-05-20 19:30");
|
|
||||||
Assert.True(result.IsSuccess);
|
|
||||||
Assert.Equal(2026, result.Value.Year);
|
|
||||||
Assert.Equal(5, result.Value.Month);
|
|
||||||
Assert.Equal(20, result.Value.Day);
|
|
||||||
Assert.Equal(19, result.Value.Hour);
|
|
||||||
Assert.Equal(30, result.Value.Minute);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ParseTimeInput_ShouldRejectPastDate()
|
|
||||||
{
|
|
||||||
var result = DiscordNewSessionHandler.ParseTimeInput("2020-01-01 00:00");
|
|
||||||
Assert.False(result.IsSuccess);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ParseTimeInput_ShouldParseRussianDateFormat()
|
|
||||||
{
|
|
||||||
var result = DiscordNewSessionHandler.ParseTimeInput("20.05.2026 19:30");
|
|
||||||
Assert.True(result.IsSuccess);
|
|
||||||
Assert.Equal(2026, result.Value.Year);
|
|
||||||
Assert.Equal(5, result.Value.Month);
|
|
||||||
Assert.Equal(20, result.Value.Day);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ParseTimeInput_ShouldRejectInvalidFormat()
|
|
||||||
{
|
|
||||||
var result = DiscordNewSessionHandler.ParseTimeInput("not-a-date");
|
|
||||||
Assert.False(result.IsSuccess);
|
|
||||||
Assert.NotNull(result.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3.2: Run test — verify RED
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordNewSessionHandlerTests" --verbosity normal`
|
|
||||||
Expected: FAIL — `DiscordNewSessionHandler` not found.
|
|
||||||
|
|
||||||
### Step 3.3: Write minimal implementation
|
|
||||||
|
|
||||||
Create `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using Dapper;
|
|
||||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
|
||||||
using GmRelay.Shared.Domain;
|
|
||||||
using GmRelay.Shared.Platform;
|
|
||||||
using GmRelay.Shared.Rendering;
|
|
||||||
using Npgsql;
|
|
||||||
|
|
||||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
|
||||||
|
|
||||||
public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, string? Error);
|
|
||||||
|
|
||||||
public sealed class DiscordNewSessionHandler(
|
|
||||||
NpgsqlDataSource dataSource,
|
|
||||||
DiscordPermissionChecker permissionChecker,
|
|
||||||
IPlatformMessenger messenger,
|
|
||||||
ILogger<DiscordNewSessionHandler> logger)
|
|
||||||
{
|
|
||||||
public static TimeParseResult ParseTimeInput(string input)
|
|
||||||
{
|
|
||||||
if (DateTimeOffset.TryParseExact(
|
|
||||||
input.Trim(),
|
|
||||||
"yyyy-MM-dd HH:mm",
|
|
||||||
System.Globalization.CultureInfo.InvariantCulture,
|
|
||||||
System.Globalization.DateTimeStyles.AssumeUniversal,
|
|
||||||
out var result))
|
|
||||||
{
|
|
||||||
if (result < DateTimeOffset.UtcNow)
|
|
||||||
return new TimeParseResult(false, default, "Дата находится в прошлом.");
|
|
||||||
|
|
||||||
return new TimeParseResult(true, result.ToUniversalTime(), null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (DateTimeOffset.TryParseExact(
|
|
||||||
input.Trim(),
|
|
||||||
"dd.MM.yyyy HH:mm",
|
|
||||||
System.Globalization.CultureInfo.InvariantCulture,
|
|
||||||
System.Globalization.DateTimeStyles.AssumeUniversal,
|
|
||||||
out var altResult))
|
|
||||||
{
|
|
||||||
if (altResult < DateTimeOffset.UtcNow)
|
|
||||||
return new TimeParseResult(false, default, "Дата находится в прошлом.");
|
|
||||||
|
|
||||||
return new TimeParseResult(true, altResult.ToUniversalTime(), null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm");
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<SessionBatchViewModel> HandleAsync(
|
|
||||||
string guildId,
|
|
||||||
string channelId,
|
|
||||||
ulong userId,
|
|
||||||
string userDisplayName,
|
|
||||||
IEnumerable<ulong> userRoles,
|
|
||||||
ulong guildOwnerId,
|
|
||||||
string title,
|
|
||||||
DateTimeOffset scheduledAt,
|
|
||||||
int? maxPlayers,
|
|
||||||
string? joinLink,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Resolve db managers
|
|
||||||
var dbManagerUserIds = await connection.QueryAsync<ulong>(
|
|
||||||
@"SELECT CAST(p.external_user_id AS BIGINT)
|
|
||||||
FROM group_managers gm
|
|
||||||
JOIN players p ON p.id = gm.player_id
|
|
||||||
JOIN game_groups g ON g.id = gm.group_id
|
|
||||||
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
|
|
||||||
new { GuildId = guildId });
|
|
||||||
|
|
||||||
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, userRoles, dbManagerUserIds))
|
|
||||||
{
|
|
||||||
throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут создавать сессии.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Upsert player
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
@"INSERT INTO players (display_name, platform, external_user_id, external_username)
|
|
||||||
VALUES (@Name, 'Discord', @UserId, @Name)
|
|
||||||
ON CONFLICT (platform, external_user_id)
|
|
||||||
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
|
|
||||||
DO UPDATE SET display_name = EXCLUDED.display_name,
|
|
||||||
external_username = EXCLUDED.external_username",
|
|
||||||
new { Name = userDisplayName, UserId = userId.ToString() },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
// Upsert group
|
|
||||||
var groupId = await connection.ExecuteScalarAsync<Guid>(
|
|
||||||
@"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
|
|
||||||
VALUES (@GuildId, 'Discord', @GuildId, @ChannelId)
|
|
||||||
ON CONFLICT (platform, external_group_id)
|
|
||||||
WHERE platform IS NOT NULL AND external_group_id IS NOT NULL
|
|
||||||
DO UPDATE SET name = EXCLUDED.name,
|
|
||||||
external_channel_id = COALESCE(EXCLUDED.external_channel_id, game_groups.external_channel_id)
|
|
||||||
RETURNING id",
|
|
||||||
new { GuildId = guildId, ChannelId = channelId },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
// Ensure manager record
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
@"INSERT INTO group_managers (group_id, player_id, role)
|
|
||||||
SELECT @GroupId, p.id, @OwnerRole
|
|
||||||
FROM players p
|
|
||||||
WHERE p.platform = 'Discord' AND p.external_user_id = @UserId
|
|
||||||
ON CONFLICT (group_id, player_id) DO NOTHING",
|
|
||||||
new { GroupId = groupId, UserId = userId.ToString(), OwnerRole = GroupManagerRoleExtensions.OwnerValue },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
// Create batch + session
|
|
||||||
var batchId = Guid.NewGuid();
|
|
||||||
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
|
||||||
@"INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players)
|
|
||||||
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers)
|
|
||||||
RETURNING id",
|
|
||||||
new
|
|
||||||
{
|
|
||||||
BatchId = batchId,
|
|
||||||
GroupId = groupId,
|
|
||||||
Title = title,
|
|
||||||
Link = joinLink ?? string.Empty,
|
|
||||||
ScheduledAt = scheduledAt.UtcDateTime,
|
|
||||||
Status = SessionStatus.Planned,
|
|
||||||
MaxPlayers = maxPlayers
|
|
||||||
},
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
await transaction.CommitAsync(cancellationToken);
|
|
||||||
|
|
||||||
var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) };
|
|
||||||
var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
|
|
||||||
|
|
||||||
await messenger.SendScheduleAsync(
|
|
||||||
new PlatformScheduleMessage(
|
|
||||||
new PlatformGroup(PlatformKind.Discord, guildId, guildId, channelId),
|
|
||||||
view,
|
|
||||||
null),
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
return view;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
await transaction.RollbackAsync(cancellationToken);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Create `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using NetCord.Rest;
|
|
||||||
using NetCord.Services.ApplicationCommands;
|
|
||||||
|
|
||||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
|
||||||
|
|
||||||
[SlashCommand("newsession", "Create a new game session")]
|
|
||||||
public class DiscordNewSessionCommand : SlashCommandModule<SlashCommandContext>
|
|
||||||
{
|
|
||||||
private readonly DiscordNewSessionHandler _handler;
|
|
||||||
|
|
||||||
public DiscordNewSessionCommand(DiscordNewSessionHandler handler)
|
|
||||||
{
|
|
||||||
_handler = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
[SlashCommandOption("title", "Game title", Required = true)]
|
|
||||||
public string Title { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[SlashCommandOption("time", "Session time (YYYY-MM-DD HH:mm or DD.MM.YYYY HH:mm)", Required = true)]
|
|
||||||
public string Time { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[SlashCommandOption("seats", "Maximum number of players", Required = false)]
|
|
||||||
public long? Seats { get; set; }
|
|
||||||
|
|
||||||
[SlashCommandOption("link", "Join link", Required = false)]
|
|
||||||
public string? Link { get; set; }
|
|
||||||
|
|
||||||
public override async Task ExecuteAsync()
|
|
||||||
{
|
|
||||||
var guild = Context.Guild
|
|
||||||
?? throw new InvalidOperationException("This command can only be used in a guild.");
|
|
||||||
|
|
||||||
var timeResult = DiscordNewSessionHandler.ParseTimeInput(Time);
|
|
||||||
if (!timeResult.IsSuccess)
|
|
||||||
{
|
|
||||||
await Context.Interaction.SendResponseAsync(
|
|
||||||
InteractionCallback.Message($"❌ {timeResult.Error}"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var view = await _handler.HandleAsync(
|
|
||||||
guildId: guild.Id.ToString(),
|
|
||||||
channelId: Context.Channel.Id.ToString(),
|
|
||||||
userId: Context.User.Id,
|
|
||||||
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
|
|
||||||
userRoles: Context.GuildUser!.RoleIds,
|
|
||||||
guildOwnerId: guild.OwnerId,
|
|
||||||
title: Title,
|
|
||||||
scheduledAt: timeResult.Value,
|
|
||||||
maxPlayers: Seats is null ? null : (int)Seats.Value,
|
|
||||||
joinLink: Link,
|
|
||||||
Context.CancellationToken);
|
|
||||||
|
|
||||||
await Context.Interaction.SendResponseAsync(
|
|
||||||
InteractionCallback.Message("✅ Сессия создана!"));
|
|
||||||
}
|
|
||||||
catch (UnauthorizedAccessException ex)
|
|
||||||
{
|
|
||||||
await Context.Interaction.SendResponseAsync(
|
|
||||||
InteractionCallback.Message($"⛅ {ex.Message}"));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await Context.Interaction.SendResponseAsync(
|
|
||||||
InteractionCallback.Message("💥 Произошла ошибка при создании сессии."));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3.4: Run test — verify GREEN
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordNewSessionHandlerTests" --verbosity normal`
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
### Step 3.5: Commit
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs
|
|
||||||
git commit -m "feat(discord): add /newsession slash command and handler
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: DiscordPlatformMessenger
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs`
|
|
||||||
- Test: `tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs`
|
|
||||||
|
|
||||||
**Context:** Необходима реализация `IPlatformMessenger` для отправки schedule embeds и обновления существующих сообщений в Discord. Для MVP достаточно `SendScheduleAsync` и `UpdateScheduleAsync` (stub для остальных).
|
|
||||||
|
|
||||||
### Step 4.1: Write the failing test
|
|
||||||
|
|
||||||
Create `tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
|
||||||
using GmRelay.Shared.Platform;
|
|
||||||
using GmRelay.Shared.Rendering;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Discord;
|
|
||||||
|
|
||||||
public sealed class DiscordPlatformMessengerTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Constructor_ShouldAcceptRestClient()
|
|
||||||
{
|
|
||||||
// DiscordPlatformMessenger requires a NetCord.Rest.RestClient.
|
|
||||||
// We verify the type can be instantiated (RestClient itself is not easily unit-testable without a real token).
|
|
||||||
// This test proves the contract exists and compiles.
|
|
||||||
var constructor = typeof(DiscordPlatformMessenger).GetConstructor(new[] { typeof(NetCord.Rest.RestClient) });
|
|
||||||
Assert.NotNull(constructor);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DiscordPlatformMessenger_ShouldImplementIPlatformMessenger()
|
|
||||||
{
|
|
||||||
Assert.True(typeof(IPlatformMessenger).IsAssignableFrom(typeof(DiscordPlatformMessenger)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4.2: Run test — verify RED
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPlatformMessengerTests" --verbosity normal`
|
|
||||||
Expected: FAIL — `DiscordPlatformMessenger` not found.
|
|
||||||
|
|
||||||
### Step 4.3: Write minimal implementation
|
|
||||||
|
|
||||||
Create `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using GmRelay.DiscordBot.Rendering;
|
|
||||||
using GmRelay.Shared.Platform;
|
|
||||||
using GmRelay.Shared.Rendering;
|
|
||||||
using NetCord;
|
|
||||||
using NetCord.Rest;
|
|
||||||
|
|
||||||
namespace GmRelay.DiscordBot.Infrastructure.Discord;
|
|
||||||
|
|
||||||
public sealed class DiscordPlatformMessenger(RestClient restClient) : IPlatformMessenger
|
|
||||||
{
|
|
||||||
public async Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(message.View);
|
|
||||||
|
|
||||||
var channelId = ulong.Parse(message.Group.ExternalChannelId
|
|
||||||
?? message.Group.ExternalGroupId);
|
|
||||||
|
|
||||||
var msg = await restClient.SendMessageAsync(
|
|
||||||
channelId,
|
|
||||||
new MessageProperties()
|
|
||||||
.WithEmbeds(embeds)
|
|
||||||
.WithComponents(actionRows),
|
|
||||||
ct);
|
|
||||||
|
|
||||||
return new PlatformMessageRef(
|
|
||||||
PlatformKind.Discord,
|
|
||||||
message.Group.ExternalGroupId,
|
|
||||||
null,
|
|
||||||
msg.Id.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
|
|
||||||
{
|
|
||||||
if (message.ExistingMessage is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(message.View);
|
|
||||||
|
|
||||||
var channelId = ulong.Parse(message.Group.ExternalChannelId
|
|
||||||
?? message.Group.ExternalGroupId);
|
|
||||||
var messageId = ulong.Parse(message.ExistingMessage.ExternalMessageId);
|
|
||||||
|
|
||||||
await restClient.ModifyMessageAsync(
|
|
||||||
channelId,
|
|
||||||
messageId,
|
|
||||||
new MessageProperties()
|
|
||||||
.WithEmbeds(embeds)
|
|
||||||
.WithComponents(actionRows),
|
|
||||||
ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct)
|
|
||||||
{
|
|
||||||
// MVP: not needed for /newsession and /listsessions
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
|
|
||||||
{
|
|
||||||
// MVP: not needed
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct)
|
|
||||||
{
|
|
||||||
// MVP: not needed (commands answer inline via SlashCommandContext)
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct)
|
|
||||||
{
|
|
||||||
// MVP: not needed
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4.4: Run test — verify GREEN
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPlatformMessengerTests" --verbosity normal`
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
### Step 4.5: Commit
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs
|
|
||||||
git commit -m "feat(discord): add DiscordPlatformMessenger IPlatformMessenger implementation
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5: Wire up DI and Register Commands
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/GmRelay.DiscordBot/Program.cs`
|
|
||||||
|
|
||||||
### Step 5.1: Write the failing test (structure test)
|
|
||||||
|
|
||||||
Modify `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs` — add test that asserts new handlers are registered:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[Fact]
|
|
||||||
public void Program_ShouldRegisterDiscordSessionHandlers()
|
|
||||||
{
|
|
||||||
var program = ReadProgram();
|
|
||||||
Assert.Contains("DiscordListSessionsHandler", program);
|
|
||||||
Assert.Contains("DiscordNewSessionHandler", program);
|
|
||||||
Assert.Contains("DiscordPermissionChecker", program);
|
|
||||||
Assert.Contains("DiscordPlatformMessenger", program);
|
|
||||||
Assert.Contains("IPlatformMessenger", program);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5.2: Run test — verify RED
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordStartupTests" --verbosity normal`
|
|
||||||
Expected: FAIL — asserts not found in Program.cs.
|
|
||||||
|
|
||||||
### Step 5.3: Write minimal implementation
|
|
||||||
|
|
||||||
Modify `src/GmRelay.DiscordBot/Program.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using GmRelay.DiscordBot.Features.Sessions;
|
|
||||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
|
||||||
using GmRelay.Shared.Platform;
|
|
||||||
|
|
||||||
// ... existing usings ...
|
|
||||||
|
|
||||||
builder.Services.AddSingleton<DiscordPermissionChecker>();
|
|
||||||
builder.Services.AddSingleton<DiscordListSessionsHandler>();
|
|
||||||
builder.Services.AddSingleton<DiscordNewSessionHandler>();
|
|
||||||
builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>();
|
|
||||||
|
|
||||||
// After host.Build():
|
|
||||||
host.AddSlashCommand("listsessions", "Show upcoming game sessions", async (DiscordListSessionsHandler handler, SlashCommandContext context) =>
|
|
||||||
{
|
|
||||||
// NetCord module-based approach preferred; if AddSlashCommand lambda doesn't support DI injection of custom services,
|
|
||||||
// rely on module classes registered via AddApplicationCommands
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important:** NetCord module classes (`DiscordListSessionsCommand`, `DiscordNewSessionCommand`) автоматически регистрируются через `AddApplicationCommands()` + `AddGatewayHandlers(typeof(Program).Assembly)`. Constructor injection в модулях работает через DI контейнер. Никаких дополнительных `AddSlashCommand` для модулей не требуется.
|
|
||||||
|
|
||||||
Убедиться, что в Program.cs есть:
|
|
||||||
```csharp
|
|
||||||
builder.Services.AddSingleton<DiscordPermissionChecker>();
|
|
||||||
builder.Services.AddSingleton<DiscordListSessionsHandler>();
|
|
||||||
builder.Services.AddSingleton<DiscordNewSessionHandler>();
|
|
||||||
builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5.4: Run test — verify GREEN
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordStartupTests" --verbosity normal`
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
### Step 5.5: Commit
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/GmRelay.DiscordBot/Program.cs tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs
|
|
||||||
git commit -m "feat(discord): wire up DI registrations for session handlers and messenger
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 6: Build Verification
|
|
||||||
|
|
||||||
### Step 6.1: Build DiscordBot project
|
|
||||||
|
|
||||||
Run: `dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore`
|
|
||||||
Expected: Build succeeds (0 errors, 0 warnings).
|
|
||||||
|
|
||||||
### Step 6.2: Run all tests
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal`
|
|
||||||
Expected: All tests pass.
|
|
||||||
|
|
||||||
### Step 6.3: Commit if any fixes needed
|
|
||||||
|
|
||||||
If build or tests required fixes, commit them.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 7: Version Bump
|
|
||||||
|
|
||||||
**Files to modify:**
|
|
||||||
- `Directory.Build.props`: `<Version>2.4.0</Version>`
|
|
||||||
- `compose.yaml`: обновить теги `gmrelay-bot`, `gmrelay-web`, `gmrelay-discord-bot` → `2.4.0`
|
|
||||||
- `.gitea/workflows/deploy.yml`: `VERSION: 2.4.0`
|
|
||||||
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`: `<div class="nav-version">v2.4.0</div>`
|
|
||||||
|
|
||||||
### Step 7.1: Bump version
|
|
||||||
|
|
||||||
Apply изменения ко всем 4 файлам.
|
|
||||||
|
|
||||||
### Step 7.2: Update version test
|
|
||||||
|
|
||||||
Modify `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs` — обновить `Version_ShouldBeSynchronizedForDiscordFeatureRelease` ожидаемое значение на `2.4.0`.
|
|
||||||
|
|
||||||
### Step 7.3: Run version test
|
|
||||||
|
|
||||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Version_ShouldBeSynchronizedForDiscordFeatureRelease" --verbosity normal`
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
### Step 7.4: Commit
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs
|
|
||||||
git commit -m "chore: bump version to 2.4.0
|
|
||||||
|
|
||||||
Synchronized across Directory.Build.props, compose.yaml, deploy.yml, NavMenu.razor
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Spec Coverage Self-Review
|
|
||||||
|
|
||||||
| Issue Requirement | Task |
|
|
||||||
|---|---|
|
|
||||||
| Slash command `/newsession` | Task 3 |
|
|
||||||
| Slash command `/listsessions` | Task 2 |
|
|
||||||
| Сохранение platform group identity (guild/channel) | Task 3 (game_groups.platform, external_group_id, external_channel_id) |
|
|
||||||
| Минимальная проверка прав | Task 1 + Task 3 |
|
|
||||||
| Данные пишутся в общую PostgreSQL без Telegram-only assumptions | Task 2, 3 SQL используют platform-agnostic колонки |
|
|
||||||
| `/listsessions` публикует/обновляет расписание | Task 2 + Task 4 |
|
|
||||||
|
|
||||||
**Placeholder scan:** Нет TBD, TODO, "implement later". Каждый шаг содержит конкретный код.
|
|
||||||
|
|
||||||
**Type consistency:** `DiscordPermissionChecker.CanManageSchedule` перегружен для resolved permissions (ulong bitflag). Handler передает `Context.GuildUser.RoleIds` и `guild.OwnerId`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Execution Handoff
|
|
||||||
|
|
||||||
**Plan complete and saved to `docs/superpowers/plans/2026-05-19-discord-newsession-listsessions.md`.**
|
|
||||||
|
|
||||||
**Two execution options:**
|
|
||||||
|
|
||||||
1. **Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration
|
|
||||||
2. **Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints for review
|
|
||||||
|
|
||||||
**Which approach?**
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,44 +0,0 @@
|
|||||||
# Telegram Mini App Dashboard Design
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Issue #17 adds a Telegram Mini App dashboard as the mobile entry point for the existing Web Dashboard. Owner and co-GM users must be able to open the dashboard from Telegram, pass server-side Telegram WebApp `initData` validation, and manage only their own groups.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Add Mini App authentication using Telegram WebApp `initData`.
|
|
||||||
- Add a `/miniapp` entry page that signs the user into the existing cookie auth flow, then opens the regular dashboard UI in mobile-first mode.
|
|
||||||
- Reuse `AuthorizedSessionService`, `SessionService`, and existing Blazor pages for groups, sessions, templates, waitlist promotion, edit forms, and bulk batch operations.
|
|
||||||
- Add bot entry points: a Mini App button in `/start` and a configurable default menu button when `Telegram:MiniAppUrl` is set.
|
|
||||||
- Update README, wiki, deployment config, and visible version strings to `1.9.0`.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
The Mini App is not a second dashboard implementation. It is a Telegram-authenticated entrance into the existing Blazor dashboard. This keeps authorization, domain operations, Telegram message synchronization, and Web Dashboard behavior in one place.
|
|
||||||
|
|
||||||
`TelegramAuthService` gains a second verification method for WebApp `initData`. The server accepts the raw URL-encoded init payload at `/auth/telegram-webapp`, verifies the Telegram HMAC with the bot token, extracts the user id/name from the embedded `user` JSON, and issues the same auth cookie as the login widget endpoint.
|
|
||||||
|
|
||||||
`/miniapp` loads `telegram-web-app.js`, posts `window.Telegram.WebApp.initData` to the server endpoint, expands the WebApp viewport, and redirects to `/`. If a user opens `/miniapp` outside Telegram, the page shows the regular login fallback.
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
1. User opens the Mini App from the bot menu button or `/start` inline button.
|
|
||||||
2. Telegram injects `initData` into the WebApp JavaScript API.
|
|
||||||
3. `/miniapp` posts `{ initData }` to `/auth/telegram-webapp`.
|
|
||||||
4. The server verifies the WebApp signature and expiry.
|
|
||||||
5. The server creates the same claims used by Telegram Login Widget.
|
|
||||||
6. Existing Blazor pages load groups through `AuthorizedSessionService`.
|
|
||||||
7. Any edit, waitlist, template, or batch action still goes through existing services and keeps Telegram messages synchronized.
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
- Missing or invalid init data returns `401` and leaves the user on the Mini App page.
|
|
||||||
- Expired auth data is rejected with the same 24-hour window used by the Login Widget.
|
|
||||||
- A verified Telegram user with no owner/co-GM groups sees the existing empty dashboard state.
|
|
||||||
- Direct navigation to a foreign group/session still redirects to `/access-denied` through existing authorization checks.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- Unit tests cover valid and invalid WebApp `initData`.
|
|
||||||
- File-level regression tests ensure `/miniapp`, `/auth/telegram-webapp`, Telegram WebApp script loading, bot Mini App button, menu button setup, and mobile Mini App CSS hooks remain present.
|
|
||||||
- Existing `AuthorizedSessionServiceTests` continue covering owner/co-GM access behavior.
|
|
||||||
-140
@@ -1,140 +0,0 @@
|
|||||||
# Platform Messenger Scheduler Notifications Design
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Issue #31 moves scheduler-driven notifications and reschedule deadline message updates behind `IPlatformMessenger`, preserving Telegram behavior and adding full Discord support instead of no-op placeholders.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- `SessionSchedulerService` remains the trigger orchestrator, but scheduler handlers stop depending on Telegram API types for outbound notification work.
|
|
||||||
- Confirmation requests, one-hour reminders, join-link notifications, RSVP follow-up messages, and reschedule deadline updates use platform-neutral contracts.
|
|
||||||
- Telegram keeps the current user-visible behavior: same message content, RSVP buttons, direct messages, topic/thread targeting, and stored legacy message ids.
|
|
||||||
- Discord receives full channel and direct notifications:
|
|
||||||
- confirmation requests are sent to the Discord channel with RSVP buttons;
|
|
||||||
- Discord RSVP button clicks update participant RSVP state, refresh the confirmation message, and send the same group/GM outcome notifications where applicable;
|
|
||||||
- one-hour reminders and join-link notifications are sent as Discord DMs when direct notifications are enabled;
|
|
||||||
- join-link notifications also post the channel message with participant mentions;
|
|
||||||
- reschedule deadline processing updates Discord vote and schedule messages through the same messenger boundary.
|
|
||||||
- Discord DM failures are non-fatal: log a warning and continue without posting a public fallback message.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
The platform boundary should be semantic, not Telegram-shaped. `GmRelay.Shared.Platform` already owns `PlatformKind`, `PlatformUser`, `PlatformGroup`, `PlatformMessageRef`, and `IPlatformMessenger`; issue #31 extends that layer with notification-specific DTOs and messenger methods.
|
|
||||||
|
|
||||||
The scheduler handlers own database queries and notification eligibility. They load platform-neutral groups, users, message refs, and session data, then ask the platform messenger to send or update the platform message. Platform implementations own rendering details: Telegram renders HTML and inline keyboards; Discord renders embeds, components, channel messages, mentions, and DMs.
|
|
||||||
|
|
||||||
RSVP handling should become platform-neutral enough for both Telegram and Discord. The current `HandleRsvpHandler` logic is not duplicated. Its command changes from Telegram ids to `PlatformUser`, `PlatformGroup`, `PlatformMessageRef`, and `InteractionId`. Telegram update routing maps callback queries into that command; Discord component routing maps RSVP button interactions into the same command.
|
|
||||||
|
|
||||||
Reschedule finalization already has shared database logic in `RescheduleVotingFinalizer`. The remaining platform-specific deadline services should stop editing messages through `ITelegramBotClient` or Discord `RestClient` directly. They should load message refs and call `IPlatformMessenger` to update vote messages, schedule messages, and direct result notifications.
|
|
||||||
|
|
||||||
## Platform Contracts
|
|
||||||
|
|
||||||
Add semantic notification records in `GmRelay.Shared.Platform`, with names finalized during implementation planning:
|
|
||||||
|
|
||||||
- `PlatformSessionParticipant`: a `PlatformUser` plus RSVP, registration, and display metadata needed by notification renderers.
|
|
||||||
- `PlatformSessionNotification`: common session title, time, join link, notification mode, group, optional existing message, and participants.
|
|
||||||
- `PlatformConfirmationRequest`: confirmation-specific session notification with RSVP actions.
|
|
||||||
- `PlatformJoinLinkNotification`: join-link group/direct notification data.
|
|
||||||
- `PlatformOneHourReminder`: one-hour direct reminder data.
|
|
||||||
- `PlatformRsvpMessageUpdate`: refreshed confirmation message state after a participant responds.
|
|
||||||
- `PlatformRescheduleVoteUpdate`: finalized reschedule vote message state, including selected option or rejection reason.
|
|
||||||
|
|
||||||
Extend `IPlatformMessenger` with methods for these semantic operations while keeping existing schedule, group, private, interaction, and calendar methods intact for current flows:
|
|
||||||
|
|
||||||
- send and update confirmation request messages;
|
|
||||||
- send one-hour reminder direct notifications;
|
|
||||||
- send join-link channel and direct notifications;
|
|
||||||
- update finalized reschedule vote messages;
|
|
||||||
- send RSVP outcome messages to the group and GM recipients.
|
|
||||||
|
|
||||||
The exact method names should be chosen in the implementation plan after tests define the desired API, but each method should accept platform-neutral DTOs and return `PlatformMessageRef` when the caller must persist a sent message id.
|
|
||||||
|
|
||||||
## Telegram Behavior
|
|
||||||
|
|
||||||
Telegram implementation lives in `GmRelay.Bot.Infrastructure.Telegram.TelegramPlatformMessenger`.
|
|
||||||
|
|
||||||
It must preserve:
|
|
||||||
|
|
||||||
- `messageThreadId` handling for forum topics;
|
|
||||||
- HTML parse mode where the existing flow uses HTML;
|
|
||||||
- current confirmation and RSVP button callback payloads;
|
|
||||||
- `confirmation_message_id` and `link_message_id` storage in `sessions`;
|
|
||||||
- direct notification behavior controlled by `SessionNotificationMode`;
|
|
||||||
- warning-and-continue behavior for failed direct messages;
|
|
||||||
- existing schedule rendering through `TelegramSessionBatchRenderer` and `BatchMessageEditor`.
|
|
||||||
|
|
||||||
Telegram-specific inbound parsing remains at the Telegram boundary. `UpdateRouter` can still use `Telegram.Bot.Types`, but the command it passes into the RSVP handler should be platform-neutral.
|
|
||||||
|
|
||||||
## Discord Behavior
|
|
||||||
|
|
||||||
Discord implementation lives in `GmRelay.DiscordBot.Infrastructure.Discord.DiscordPlatformMessenger`.
|
|
||||||
|
|
||||||
It must support:
|
|
||||||
|
|
||||||
- channel messages through the configured channel id in `PlatformGroup.ExternalChannelId`;
|
|
||||||
- interactive RSVP buttons routed by `DiscordSessionInteractionModule`;
|
|
||||||
- ephemeral interaction replies via the existing `DiscordInteractionReplyCache` pattern;
|
|
||||||
- DMs through Discord user ids in `PlatformUser.ExternalUserId`;
|
|
||||||
- non-fatal DM failures with warning logs;
|
|
||||||
- Discord-friendly rendering, not raw Telegram HTML;
|
|
||||||
- persistence of Discord schedule and notification message refs in `platform_messages` where later updates need them.
|
|
||||||
|
|
||||||
The current Discord reschedule deadline service directly uses `RestClient` for vote and schedule message edits. This should be folded into `DiscordPlatformMessenger` so deadline services and future platform handlers do not need to know Discord API details.
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
1. `SessionSchedulerService.TickAsync` asks `ISessionTriggerStore` for due confirmation, one-hour reminder, and join-link session ids.
|
|
||||||
2. Each handler loads the session, group platform identity, message refs, participants, RSVP state, and notification mode.
|
|
||||||
3. The handler builds a semantic platform notification DTO and calls `IPlatformMessenger`.
|
|
||||||
4. The messenger renders and sends/updates platform messages.
|
|
||||||
5. The handler persists sent message ids where required, using legacy `sessions.confirmation_message_id` and `sessions.link_message_id` for Telegram and `platform_messages` for Discord refs that need later updates.
|
|
||||||
6. Telegram callback queries and Discord component interactions both call the same platform-neutral RSVP handler.
|
|
||||||
7. Reschedule deadline services use `RescheduleVotingFinalizer`, then call `IPlatformMessenger` for vote message updates, schedule updates, and direct result notifications.
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
- A failed trigger query still logs and lets the scheduler continue to the next trigger category.
|
|
||||||
- A failed send/update for one session logs and does not stop other sessions in the same tick.
|
|
||||||
- DM failures are warning-level and non-fatal for Telegram and Discord.
|
|
||||||
- A missing platform message ref logs a warning and skips only the update that needs the ref.
|
|
||||||
- Unsupported platform values throw at the messenger boundary, not inside scheduler orchestration.
|
|
||||||
- If Discord cannot parse a stored channel, message, or user id, it logs the bad external id and skips that platform send/update.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Use TDD for implementation.
|
|
||||||
|
|
||||||
Focused tests should cover:
|
|
||||||
|
|
||||||
- `IPlatformMessenger` exposes semantic notification methods without referencing Telegram or Discord assemblies from `GmRelay.Shared`.
|
|
||||||
- `SendConfirmationHandler`, `SendOneHourReminderHandler`, `SendJoinLinkHandler`, `HandleRsvpHandler`, and reschedule deadline services do not call `ITelegramBotClient`, `BatchMessageEditor`, or Discord `RestClient` directly for notification output.
|
|
||||||
- Telegram source/regression tests preserve thread ids, callback payloads, message id persistence, and direct notification mode behavior.
|
|
||||||
- Discord source tests verify registration of scheduler handlers, RSVP component routes, and messenger methods.
|
|
||||||
- RSVP flow tests run through platform-neutral `PlatformUser` identity, including Discord users without Telegram ids.
|
|
||||||
- Discord messenger tests verify DMs are attempted, DM failures are swallowed after logging, channel notifications include buttons or mentions as appropriate, and message refs are returned.
|
|
||||||
- Full regression: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj`, `dotnet build`, and `dotnet format --verify-no-changes --verbosity diagnostic`.
|
|
||||||
|
|
||||||
## Versioning
|
|
||||||
|
|
||||||
Current repository version is `2.6.0`. Although the Gitea issue is labeled `type:refactor`, the approved scope adds full Discord notification behavior. Proposed bump: `2.6.0` to `2.7.0`.
|
|
||||||
|
|
||||||
Synchronize:
|
|
||||||
|
|
||||||
- `Directory.Build.props`
|
|
||||||
- `compose.yaml` image tags for bot, discord, and web
|
|
||||||
- `.gitea/workflows/deploy.yml` `VERSION`
|
|
||||||
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
|
|
||||||
|
|
||||||
## Out Of Scope
|
|
||||||
|
|
||||||
- Moving the entire scheduler hosted service into `GmRelay.Shared`.
|
|
||||||
- Removing legacy Telegram columns such as `telegram_chat_id`, `confirmation_message_id`, or `link_message_id`.
|
|
||||||
- Reworking Web dashboard Telegram behavior.
|
|
||||||
- Public fallback messages when a Discord DM is blocked.
|
|
||||||
|
|
||||||
## Self-Review
|
|
||||||
|
|
||||||
- Spec coverage: every issue acceptance criterion is represented by scheduler handler boundaries, messenger contracts, Telegram behavior preservation, and Discord implementation requirements.
|
|
||||||
- Placeholder scan: no TBD/TODO/fill-in-later sections remain.
|
|
||||||
- Internal consistency: the design uses semantic platform DTOs consistently and keeps SDK-specific rendering in platform implementations.
|
|
||||||
- Scope check: the work is large but still one coherent platform-notification refactor; moving the whole scheduler to shared remains explicitly out of scope.
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
# Дизайн: Синхронизация документации после MVP2 (Discord + кросс-платформенность)
|
|
||||||
|
|
||||||
**Дата:** 2026-05-21
|
|
||||||
**Версия проекта:** v2.7.2
|
|
||||||
**Статус:** Approved
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Цель
|
|
||||||
|
|
||||||
Привести всю проектную документацию в актуальное состояние после завершения MVP2:
|
|
||||||
- Discord-интеграция (slash-команды, кнопки, RSVP, reschedule voting, DM-уведомления).
|
|
||||||
- Кросс-платформенная архитектура (`IPlatformMessenger`, `SessionBatchViewBuilder`, platform-specific renderers).
|
|
||||||
- Новые env-переменные (`DISCORD_BOT_TOKEN`), healthcheck на 8082, Docker Compose сервис `discord`.
|
|
||||||
- Регрессионные тесты, обновлённый CI/CD.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Аудитории и каналы
|
|
||||||
|
|
||||||
| Аудитория | Канал | Фокус |
|
|
||||||
|---|---|---|
|
|
||||||
| ГМы и игроки | Gitea Wiki | Как пользоваться ботом: команды, кнопки, уведомления, FAQ |
|
|
||||||
| Разработчики и хостеры | `README.md` + `docs/` | Архитектура, сборка, деплой, env-переменные, ADR |
|
|
||||||
|
|
||||||
**Принцип:** Wiki — только пользовательская документация. Технические детали (архитектура, БД, разработка) удаляются из Wiki и консолидируются в репозитории.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Wiki (пользовательская документация)
|
|
||||||
|
|
||||||
### Новая структура страниц
|
|
||||||
|
|
||||||
1. **Home**
|
|
||||||
- Общее описание GM-Relay (Telegram + Discord).
|
|
||||||
- Текущая версия v2.7.2.
|
|
||||||
- Ссылки: Быстрый старт, Руководство ГМа, Руководство игрока.
|
|
||||||
- Убираем: технический стек, ссылки на Архитектуру/БД/Разработка.
|
|
||||||
|
|
||||||
2. **Быстрый старт**
|
|
||||||
- Шаг 1: Добавление Telegram-бота в группу.
|
|
||||||
- Шаг 2: Приглашение Discord-приложения на сервер (scopes: bot, applications.commands).
|
|
||||||
- Шаг 3: Создание первой группы (`/newgroup` в Telegram или через Web).
|
|
||||||
- Шаг 4: Создание первого batch (`/newsession`).
|
|
||||||
- Шаг 5: Публикация расписания (`/listsessions`).
|
|
||||||
|
|
||||||
3. **Руководство ГМа**
|
|
||||||
- Telegram-команды: `/newgroup`, `/newsession`, `/listsessions`, `/exportcalendar`.
|
|
||||||
- Discord slash-команды: `/newsession`, `/listsessions`.
|
|
||||||
- Создание и управление batch: картинки, повторы, bulk-операции (Web).
|
|
||||||
- Co-GM и делегирование.
|
|
||||||
- Переносы (reschedule): как инициировать голосование, как работает дедлайн.
|
|
||||||
- Шаблоны кампаний.
|
|
||||||
- Статистика посещаемости (Web).
|
|
||||||
- Управление очередью (waitlist, promote).
|
|
||||||
|
|
||||||
4. **Руководство игрока**
|
|
||||||
- Telegram: запись через inline-кнопки, отмена.
|
|
||||||
- Discord: кнопки Join/Leave в schedule message, RSVP (Confirm/Decline).
|
|
||||||
- Уведомления: за 24ч, за 1ч, ссылка перед игрой, DM vs группа.
|
|
||||||
- Лист ожидания: как попасть, как автоматически продвинуться.
|
|
||||||
|
|
||||||
5. **FAQ / Устранение неполадок**
|
|
||||||
- Бот не отвечает: проверить права, перезапустить.
|
|
||||||
- Кнопки не работают: проверить права Manage Messages / Embed Links.
|
|
||||||
- Mini App не открывается: HTTPS, domain в BotFather.
|
|
||||||
- Discord DM не приходят: privacy settings, бот не может писать first.
|
|
||||||
- Reschedule голосование не завершилось: дедлайн, минимум голосов.
|
|
||||||
|
|
||||||
### Удаляемые Wiki-страницы (контент переходит в README/docs)
|
|
||||||
|
|
||||||
- `Архитектура` → `docs/c4-system-context.md` + `docs/adr/`
|
|
||||||
- `База данных` → `docs/adr/` (описание схемы)
|
|
||||||
- `Разработка` → `README.md` (раздел для контрибьюторов)
|
|
||||||
- `Развёртывание` → `README.md` (Docker Compose quick start)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. README.md (разработчики и хостеры)
|
|
||||||
|
|
||||||
### Что обновить
|
|
||||||
|
|
||||||
- **Версия:** с `v2.7.0` → `v2.7.2`.
|
|
||||||
- **Key Features — Discord:**
|
|
||||||
- Slash-команды `/newsession`, `/listsessions`.
|
|
||||||
- Кнопки Join/Leave/RSVP с ephemeral-ответами.
|
|
||||||
- DM-напоминания и ссылки (с fallback-логированием).
|
|
||||||
- Reschedule voting с дедлайном.
|
|
||||||
- Waitlist и auto-promote.
|
|
||||||
- **Технологический стек:**
|
|
||||||
- Добавить NetCord Gateway для Discord.
|
|
||||||
- Уточнить: `GmRelay.DiscordBot` — это NetCord Gateway worker (не отдельный проект в solution, а runtime-роль внутри Bot/Web).
|
|
||||||
- Добавить `IPlatformMessenger` в архитектурное описание.
|
|
||||||
- **Структура репозитория:**
|
|
||||||
- Убрать `GmRelay.DiscordBot` как отдельный проект (согласно CLAUDE.md, его нет; Discord-логика внутри `GmRelay.Bot`).
|
|
||||||
- Добавить `GmRelay.ServiceDefaults`.
|
|
||||||
- **Переменные окружения:**
|
|
||||||
- Добавить `DISCORD_BOT_TOKEN`.
|
|
||||||
- Добавить `DISCORD_BOT_CLIENT_ID` (для регистрации slash-команд).
|
|
||||||
- **Docker Compose:**
|
|
||||||
- Добавить сервис `discord` с healthcheck на `:8082`.
|
|
||||||
- Уточнить multi-arch (AMD64/ARM64 для Raspberry Pi).
|
|
||||||
- **Quick Start:**
|
|
||||||
- Добавить шаг приглашения Discord-бота.
|
|
||||||
- Добавить настройку домена для Mini App.
|
|
||||||
|
|
||||||
### Новый раздел (опционально)
|
|
||||||
|
|
||||||
- **Для разработчиков:**
|
|
||||||
- Краткое описание Vertical Slice + Native AOT.
|
|
||||||
- Ссылка на `docs/adr/0001-...` и `docs/adr/002-...`.
|
|
||||||
- Как добавить handler и зарегистрировать в Program.cs.
|
|
||||||
- Как написать миграцию (DbUp).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. `docs/` (архитектурная и техническая документация)
|
|
||||||
|
|
||||||
### `docs/c4-system-context.md`
|
|
||||||
|
|
||||||
- **Level 1 (System Context):** Добавить Discord Gateway and REST API как external system. Добавить игрокам Discord-взаимодействие.
|
|
||||||
- **Level 2 (Container):** Уточнить, что `GmRelay.Bot` содержит **оба** runtime-роли: Telegram long polling **и** Discord Gateway worker (или уточнить, что Discord worker — отдельный контейнер внутри той же сборки). Проверить текущую C4-диаграмму — она уже содержит `discordBot`, так что нужно только убедиться, что он соответствует `GmRelay.Bot` (а не `GmRelay.DiscordBot`).
|
|
||||||
- **Level 3 (Component):** Уже содержит Discord-компоненты. Проверить актуальность: `DiscordSessionInteractionModule`, `DiscordPlatformMessenger`. Добавить `RescheduleVotingFinalizer` (shared). Добавить `DiscordHealthCheckHostedService`.
|
|
||||||
|
|
||||||
### `docs/adr/0001-use-vertical-slice-native-aot-and-aspire.md`
|
|
||||||
|
|
||||||
- Добавить Discord-аспект: NetCord Gateway worker, slash-команды.
|
|
||||||
- Уточнить, что Aspire оркестрирует **три** сервиса: Bot (Telegram + Discord), Web, PostgreSQL.
|
|
||||||
|
|
||||||
### `docs/adr/002-platform-neutral-batch-rendering.md`
|
|
||||||
|
|
||||||
- Уже содержит Discord renderer. Дополнить:
|
|
||||||
- Issue #30 (reschedule voting) использует `IPlatformMessenger`.
|
|
||||||
- Issue #31 (scheduler notifications) тоже использует `IPlatformMessenger`.
|
|
||||||
- Issue #32 (compose wiring) добавил Discord healthcheck.
|
|
||||||
- Issue #33 (регрессионные тесты) покрывает оба renderer'а.
|
|
||||||
|
|
||||||
### Новый ADR (опционально, если есть время)
|
|
||||||
|
|
||||||
- **ADR-003: Discord Integration Architecture** — почему NetCord (а не DSharpPlus), как Gateway events маршрутизируются в vertical slice handlers, как ephemeral-ответы работают.
|
|
||||||
- Это необязательно, но полезно для будущих разработчиков.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Порядок выполнения
|
|
||||||
|
|
||||||
1. **Wiki Home** — обновить описание, версию, ссылки.
|
|
||||||
2. **Wiki Быстрый старт** — переписать с учётом Discord.
|
|
||||||
3. **Wiki Руководство ГМа** — добавить Discord-команды, reschedule voting, статистику.
|
|
||||||
4. **Wiki Руководство игрока** — новая страница (или раздел в Руководстве ГМа).
|
|
||||||
5. **Wiki FAQ** — новая страница.
|
|
||||||
6. **README.md** — версия, features, env, Docker, quick start.
|
|
||||||
7. **`docs/c4-system-context.md`** — Discord-компоненты, healthcheck.
|
|
||||||
8. **`docs/adr/0001-...`** — Discord-аспекты.
|
|
||||||
9. **Удалить устаревшие Wiki-страницы** (Архитектура, База данных, Разработка, Развёртывание) или заменить их редиректами на README.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Критерии готовности
|
|
||||||
|
|
||||||
- [ ] Все wiki-страницы отражают текущую версию v2.7.2.
|
|
||||||
- [ ] Все Discord-фичи задокументированы для пользователей.
|
|
||||||
- [ ] README содержит актуальную версию, env-переменные, структуру репозитория.
|
|
||||||
- [ ] C4-диаграмма и ADR'ы отражают Discord-архитектуру и `IPlatformMessenger`.
|
|
||||||
- [ ] Нет противоречий между Wiki и README (например, версия, команды).
|
|
||||||
- [ ] Устаревшие wiki-страницы удалены или содержат редирект.
|
|
||||||
Reference in New Issue
Block a user