Two non-blocking suggestions from the PR #124 code review:
1. Symmetric coverage for the Discord PoolSlotCapacity no-limit button.
The Capacity step already had a customId-shape assertion for the
'♾ Без лимита' button; PoolSlotCapacity only had a label-presence
assertion. If ChoiceButtonCustomId's wire format ever diverged between
the two steps, only Capacity would catch it. Convert the single Fact
to a Theory with two InlineData rows so a regression in either step
produces a targeted test failure.
2. Brittle hard-coded version literal in NavMenu_ShouldExposeCurrent
ProjectVersion. The test had to be hand-edited on every version bump
(we hit this in PR #124 — bumping 3.9.2 -> 3.9.3 broke CI). Read the
version from Directory.Build.props via XDocument instead so the test
only fails when the rendered NavMenu actually disagrees with the
canonical version, not when someone forgot to update a literal.
Tolerant of whitespace, comments and attribute order; the <Version>
element is a plain string body in the MSBuild schema.
No production code changes; production version is bumped in a
follow-up commit.
Closes#125
In the session creation wizard (Telegram + Discord), the Capacity step
only exposed waitlist on/off buttons. The 'no waitlist' button silently
advanced to the next step without setting MaxPlayers, so users who tried
to create a session with no player cap were blocked with
'Не заполнены поля: лимит мест'.
The DB contract and CreateSessionCommand already supported null
MaxPlayers (int?, ck_sessions_max_players check in V006), and the web
form already exposes 'Без лимита' as an empty InputNumber — only the
wizard flow was broken.
Changes:
- Add '♾ Без лимита' choice button to Capacity in shared
WizardStepViewBuilder.BuildCapacity (Telegram) and to
RenderCapacity / RenderPoolSlotCapacity in DiscordWizardStep (Discord).
- Add 'no_limit' branch to GameCreationWizard.ApplyCapacityChoice that
sets MaxPlayers to null and advances to Visibility.
- Change GameCreationWizard.SetMaxPlayers signature from int to int? so
the 'no limit' branch compiles.
- Change CreateSessionCommand builder in both Telegram and Discord
submitters to take int? maxPlayers and drop the '?? 0' that would
have turned null into 0 (violating the DB CHECK and the 'no limit'
contract).
- In Discord BuildConfirmDescription, render '👥 Без лимита, waitlist
вкл/выкл' when MaxPlayers is null (the previous code silently
omitted the line).
- Expose BuildCommand as internal in both submitters and add
InternalsVisibleTo('GmRelay.Bot.Tests') to the DiscordBot assembly
for unit-test access.
Tests (9 new):
- WizardStepRenderTests.CapacityStep_HasWaitlistButtons — asserts the
'Без лимита' button is present.
- GameCreationWizardStepTransitionsTests.NoLimitCapacityButton_… —
asserts the choice advances to Visibility and leaves MaxPlayers null
in the JSON draft.
- GameCreationWizardStepTransitionsTests.ChoiceCallback_AdvancesToExpectedStep —
new Theory row for Capacity/no_limit.
- CreateSessionHandlerBuildCommandTests (new) — null/value propagation
through the Telegram submitter's BuildCommand.
- DiscordWizardStepCapacityRenderTests (new) — 'Без лимита' button is
rendered for both Capacity and PoolSlotCapacity, with the expected
custom-id shape.
- DiscordWizardSubmitterBuildCommandTests (new) — null/value
propagation through the Discord submitter's BuildCommand.
Closes#123
VERDICT from verifier (D:\Projects\Game\docs\review-report.md):
REQUEST_CHANGES — wizard was functionally broken at runtime.
## Critical
C-1. Choice-button customId was missing the 'choice:' segment.
ButtonCustomId emitted 'wizard:btn:<step>:<value>' but the
dispatcher's switch matches parts[1] == 'choice'. Every choice
button (D&D 5e, Pathfinder, Waitlist, Publish, Confirm) fell
into the default branch and showed 'Unknown button'.
Fix: split into 3 customId helpers:
ChoiceButtonCustomId(step, value) -> 'wizard:btn:choice:<step>:<value>'
ControlButtonCustomId(action) -> 'wizard:btn:<action>:1' (back/cancel/skip/create)
ModalTriggerButtonCustomId(modalStep) -> 'wizard:btn:modal:<modalStep>'
Bulk-rewrote all 66 Btn() call sites in DiscordWizardStep.cs.
C-2. "Другое…" modal-trigger buttons were unrouted in dispatcher.
Added 'parts[1] == "modal"' branch that opens the modal via
InteractionCallback.Modal(BuildModal(parts[2], draft.ChatId)).
C-3. DiscordWizardSubmitter was leaking ex.Message from
CreateSessionHandler to the user-visible draft embed. Postgres
exceptions expose schema/constraint names. Replaced with
generic user-facing error; full exception still logged
server-side on the existing catch block.
## I-3 — parser-roundtrip tests (the gap that let C-1/C-2 through)
Added two real behavioural tests (not string-grep) to
DiscordWizardInteractionModuleSourceTests:
- Renderer_And_Dispatcher_Agree_On_Wire_Format
- ControlButtons_Are_Parsed_As_Control_Not_Choice
These mirror NetCord's [ComponentInteraction("wizard")] prefix
strip, run the parser, and assert the dispatcher would route to
the right branch. Catches the entire class of 'renderer and
dispatcher disagree on the wire format' regressions.
## I-6 — BuildResumeRow (cascading fix from C-1)
After C-1, BuildResumeRow's ButtonCustomId('cancel', '1') would
emit the wrong format. Switched to direct format strings
('wizard:btn:cancel:1', 'wizard:btn:resume:continue', etc.) which
match the dispatcher's 'back'/'cancel'/'create'/'resume' cases
directly, not the 'choice' prefix.
## Version sync (3.8.0 -> 3.9.0)
Directory.Build.props: <Version>3.9.0</Version>
compose.yaml: all 3 image tags -> 3.9.0
Version_ShouldBeSynchronizedForDiscordFeatureRelease test now green.
## Stats
build: 0 warnings, 0 errors
format: 0 of 279 files need changes
tests: 583 passed, 2 skipped (pre-existing), 0 failed
files: 7 changed, 226 +, 79 -
Adds the missing inbound handlers for the Discord wizard that the
previous commit (b81d865) left out. Three thin NetCord module classes
share one WizardInteractionDispatcher:
- DiscordWizardButtonModule
- DiscordWizardStringMenuModule
- DiscordWizardModalModule
Each registers a single [ComponentInteraction("wizard")] method that
hands the args string to the dispatcher. The dispatcher parses the
custom-id tail (btn:choice:<step>:<value>, btn:back, btn:cancel,
btn:create, btn:resume:continue, btn:resume:restart, select:<step>,
modal:<step>), looks up the active draft by (platform="Discord",
ownerId=userId), and routes through the shared
GameCreationWizard.HandleInteractionAsync. The "create" callback
delegates to DiscordWizardSubmitter.SubmitAsync (3-retry finalize).
Program.cs gets 4 new singleton registrations (the dispatcher plus
the three module classes) and 2 new AddComponentInteractions calls
(StringMenu + Modal). The existing Button registration is unchanged.
12 new source-level smoke tests in DiscordWizardInteractionModuleSourceTests
cover the file shape: 3 handler classes, 3 base classes, 3
[ComponentInteraction] registrations, all 5 callback kinds parsed,
GameCreationWizard wired in, submitter invoked on create, Program.cs
registers all 3 AddComponentInteractions and all 4 module classes,
draft lookup by GetActiveAsync("Discord", ...), modal walks
Components[0] -> TextInput -> .Value, string menu reads
SelectedValues[0].
Build green. 190/190 Discord+Wizard tests pass (2 pre-existing
skipped). dotnet format clean.
- PublicSession.razor: добавлена обработка ?register=1, AuthStateProvider,
TryGetPlatformIdentity, кнопки записи для авторизованных/неавторизованных
пользователей, отображение результата регистрации
- CreateSessionHandler: добавлен cover_image_url в INSERT SQL
- DiscordProjectStructureTests: версия 3.4.0 во всех проверках
- README.md: актуальная версия v3.4.0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add publication settings for clubs and sessions, read-only public club/session pages, dashboard controls, privacy-focused public queries, docs, and tests.
Bump version to 3.3.0
Create a Telegram forum topic when Web creates a batch from a campaign template, persist thread ownership on the generated sessions, and send the batch schedule into that topic.
Bump version -> 3.1.1
DiscordNewSessionHandler.ParseTimeInput used DateTimeStyles.AssumeUniversal,
which interpreted user input as UTC. A user entering 15:00 got a session
scheduled at 18:00 MSK after rendering. Align with Telegram behavior by
treating input as Moscow time and converting to UTC before storage.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
V023 migration drops NOT NULL constraints on:
- game_groups.telegram_chat_id
- game_groups.gm_telegram_id
- players.telegram_id
This allows Discord (and future platforms) to create players and
game_groups without legacy Telegram identifiers.
Bump version → 3.0.10
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GmRelay.Shared references Dapper.AOT with PrivateAssets=all, which
prevents the runtime DLL from flowing to downstream projects. Telegram
bot works because it explicitly references Dapper.AOT directly, but
Discord bot did not — causing FileNotFoundException for Dapper.AOT
at runtime, breaking the scheduler and slash commands.
- Add Dapper.AOT 1.0.48 to GmRelay.DiscordBot.csproj
- Add regression test: DiscordWorkerProject_ShouldExist asserts
Dapper.AOT is present in the DiscordBot csproj
- Bump version → 3.0.9
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Command_ShouldRenderEmbedOnSuccess test asserted the presence of
WithEmbeds in DiscordNewSessionCommand.cs. After switching to deferred
responses (InteractionCallback.DeferredMessage + ModifyResponseAsync),
embeds are now set via message.Embeds = embeds instead.
Bump version → 3.0.8
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Add builder.Logging.AddConsole() to DiscordBot Program.cs so logs
are visible in docker logs.
- Add granular LogInformation/LogError calls to DiscordNewSessionCommand
and DiscordRescheduleCommand to diagnose failures.
- Use InteractionCallback.DeferredMessage() + ModifyResponseAsync pattern
for /newsession and /reschedule to avoid Discord 3-second interaction
timeout.
- Bump version → 3.0.8
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace REST GetGuildAsync/GetGuildUserAsync calls with authoritative
member.Permissions from the slash-command interaction payload. Discord
already resolves channel/guild permissions in the interaction JSON, so
we no longer need to fetch the guild via REST (which returns 404 when
the bot is not a REST member of the guild, e.g. user-installed apps).
Keep a best-effort GetGuildAsync call only to obtain OwnerId for the
permission checker fallback, swallowing 404 silently.
Bump version → 3.0.7
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PostgreSQL COUNT() returns bigint, but DiscordSessionListItemDto expects
int for PlayerCount and WaitlistCount. Dapper 2.1.72 in GmRelay.DiscordBot
(without Dapper.AOT) fails to materialize the record with bigint→int mismatch.
Added ::int casts to both COUNT expressions.
Bump version to 3.0.6.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Context.Guild in NetCord resolves the Guild object from the gateway client cache
(cache.Guilds.GetValueOrDefault(guildId)), not from the interaction JSON payload.
After a bot restart, the guild may not yet be cached when the first slash command
arrives, causing Context.Guild to be null even though the command is invoked
inside a guild channel. This produced "This command can only be used in a guild."
Changes:
- DiscordListSessionsCommand: use Context.Interaction.GuildId instead of Context.Guild.Id
- DiscordNewSessionCommand: use Context.Interaction.GuildId + REST GetGuildAsync/GetGuildUserAsync
- DiscordRescheduleCommand: same as above
- DiscordSessionInteractionModule: same fix for button interactions (CreateInput)
- Add null guard in GetResolvedPermissions for safety
- Bump version to 3.0.5
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Register NetCord application command modules after the host is built so module-based commands are published alongside the minimal /ping command.
Update README Discord env guidance to avoid the unused DISCORD_BOT_CLIENT_ID variable.
Bump version to 3.0.2.
- Add V020 migration: player_links + identity_audit_log tables
- Add ISessionStore methods: ResolveEffectivePlayerId, LinkIdentity, UnlinkIdentity, GetLinkedIdentities
- Update SessionService to resolve effective player id for all permission checks
- Add /auth/discord/callback linking flow when already authenticated
- Add /api/me/identities GET/DELETE endpoints
- Add Profile.razor page for managing linked accounts
- Update NavMenu with profile link and v3.0.0 badge
- Bump version to 3.0.0 across all files
Bump version → 3.0.0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Changed __DiscordOAuthState cookie from SameSite=Strict to SameSite=None
because Discord redirects from discord.com (cross-site) and Strict
prevents the cookie from being sent on the callback request.
- Added logging for CSRF validation failure to aid future diagnostics.
Bump version → 2.8.1
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Bump version to 2.8.0 across all versioned files
- Fix AuthorizedSessionServiceTests for platform-agnostic identity
- Update Razor Pages to use *ForCurrentUserAsync APIs
- Add backward-compatible constructors to WebGameGroup/WebGroupManager
- Make DiscordOAuthOptions properties non-required for config binding
Bump version → 2.8.0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Move neutral join/leave handlers into GmRelay.Shared so Telegram and Discord share capacity, waitlist, duplicate-click, and schedule-update behavior.
Add Discord component routing for join_session and leave_session buttons with deferred ephemeral replies and serialized schedule message updates.
Bump version to 2.5.0 and update Discord docs.
Refs #29
- DiscordPermissionChecker: removed dead-code userRoles overload;
now only uses resolvedPermissions bitflag (Administrator = 0x8).
- DiscordNewSessionCommand: computes resolved permissions from guild
user roles via Context.Guild.Users[Id].RoleIds + guild.Roles.
- DiscordNewSessionHandler: updated signature to accept ulong
resolvedPermissions instead of unused userRoles.
- Added ILogger to command for diagnostics on unexpected errors.
- Added test: regular user with ManageServer (but not Admin) is rejected.
Refs issue #28
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Synchronized across Directory.Build.props, compose.yaml, deploy.yml,
NavMenu.razor, and project structure tests.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Task 5: DI wiring for DiscordNewSessionHandler, DiscordListSessionsHandler,
DiscordPermissionChecker, and DiscordPlatformMessenger.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Synchronized across Directory.Build.props, compose.yaml, deploy.yml, NavMenu.razor, and DiscordProjectStructureTests.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add a separate GmRelay.DiscordBot worker using NetCord Gateway with startup token validation, PostgreSQL datasource registration, slash-command setup, component interaction service registration, and lifecycle logging.
Wire the Discord service through Aspire AppHost, Docker Compose, PR checks, deploy image build/push/scan/pull steps, README docs, and synchronized version 2.2.0.
Add TDD coverage for project isolation, token validation, startup wiring, runtime wiring, and version synchronization.
Bump version -> 2.2.0