Compare commits

...

347 Commits

Author SHA1 Message Date
Toutsu 3db2b703d6 Merge pull request #124: fix(bot,discord): allow 'no player limit' option in /newsession wizard (v3.9.3)
Deploy Telegram Bot / build-and-push (push) Successful in 7m43s
Deploy Telegram Bot / scan-images (push) Successful in 2m28s
Deploy Telegram Bot / deploy (push) Successful in 46s
Closes #123.
2026-06-08 18:47:38 +03:00
Toutsu 3c3ef8db5a test(web): update NavMenu_ShouldExposeCurrentProjectVersion to 3.9.3
PR Checks / test-and-build (pull_request) Successful in 9m57s
The version-bump commit changed the visible version in NavMenu.razor
from 3.9.2 to 3.9.3 but this test hard-coded the old literal. Update
the assertion to match the new release tag.
2026-06-08 18:31:17 +03:00
Toutsu 5c0397a5e6 chore: bump version 3.9.2 -> 3.9.3
PR Checks / test-and-build (pull_request) Failing after 10m16s
Sync the four canonical version sources for the patch release:
- Directory.Build.props
- compose.yaml (bot, discord, web image tags)
- .gitea/workflows/deploy.yml (VERSION env)
- src/GmRelay.Web/Components/Layout/NavMenu.razor (visible nav-version)
2026-06-08 18:17:26 +03:00
Toutsu 15040eb954 fix(bot,discord): allow 'no player limit' option in /newsession wizard
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
2026-06-08 18:17:01 +03:00
Toutsu 99a58d7835 ci: install Trivy from official Docker image; normalize .gitignore to UTF-8
Deploy Telegram Bot / build-and-push (push) Successful in 52s
Deploy Telegram Bot / scan-images (push) Successful in 3m0s
Deploy Telegram Bot / deploy (push) Successful in 43s
Trivy install keeps failing on the deploy workflow:

  - empty TAG: install.sh falls back to 'latest', but the
    'latest' GitHub release tag is no longer published.
  - pin v0.71.0: pin alone is not durable. The release got
    unpublished and install.sh now dies with
    'unable to find v0.71.0 - use latest'.

Switch to the official aquasec/trivy Docker image:

  - Docker Hub tags are content-addressed and rarely removed,
    so the pin is durable.
  - The image manifest ships linux/amd64, linux/arm64, linux/ppc64le
    and linux/s390x, so the same tag works on the GitHub-hosted
    runner and on the ARM64 Pi runner.
  - We just need docker pull + docker cp /usr/local/bin/trivy.
  - Pinned to 0.70.0 (April 2026) for reasonably current CVE data.

Also normalize .gitignore to plain UTF-8. The working copy had
been re-saved as UTF-16 LE by a Set-Content call without
-Encoding UTF8 (PowerShell 5.1 default on the local Windows box),
so git kept reporting 'Binary files differ' even though the rules
themselves were fine. Re-wrote through the editor to drop the
UTF-16 encoding and added rules for showcase-*.png, *.png.local,
and the test scratch dirs that keep creeping in.
2026-06-08 12:09:19 +03:00
Toutsu f491727cec chore: stop tracking AI scratch dirs and local screenshots
Deploy Telegram Bot / build-and-push (push) Successful in 5m32s
Deploy Telegram Bot / scan-images (push) Failing after 4s
Deploy Telegram Bot / deploy (push) Has been skipped
The previous commit accidentally pulled in .opencode/tmp/, .playwright-mcp/,
.superpowers/, and a handful of local screenshots/logs because
'git add -A' was used during the 3.9.2 fix. None of these affect the
build or the deploy; the deploy workflow was triggered by the version
bump and ran cleanly. Add them to .gitignore and untrack so the next
contributor doesn't commit them again.
2026-06-08 10:49:41 +03:00
Toutsu 2c9016a383 fix(shared,bot,discordbot): make club-picker Dapper calls AOT-safe (v3.9.2)
Deploy Telegram Bot / build-and-push (push) Successful in 7m18s
Deploy Telegram Bot / scan-images (push) Failing after 17s
Deploy Telegram Bot / deploy (push) Has been skipped
The 3.9.1 hotfix only repaired WizardDraftRepository, the most common
Dapper call in the wizard. The same AOT-unsafe CommandDefinition pattern
remained in 4 other places that the user hit immediately after the
deploy: the 'Choose visibility' wizard step triggers GetOwnerClubsAsync
when the user picks 'Публичная в витрине клуба' or 'Только для членов
клуба'. The wizard swallowed PlatformNotSupportedException, the
callback ack replied with '⚠️ Ошибка', and the next step never rendered.
Privacy 'didn't stick' from the user's perspective.

Two changes to fix the Discord side as well:

1. Switched GetOwnerClubsAsync / LoadClubsAsync / LoadManagerUserIdsAsync
   to the direct (sql, params) overload across TelegramWizardMessenger,
   DiscordWizardMessenger, DiscordWizardInteractionModule, and
   DiscordPermissionLookup — same pattern as the 3.9.1 fix.

2. Added Dapper.AOT module attribute ([module: Dapper.DapperAot]) and
   InterceptorsPreviewNamespaces to the DiscordBot project. The
   DiscordBot assembly was previously skipped by the AOT source
   generator, so even the direct-overload fix wouldn't have produced
   interceptors for the Discord-specific Dapper call sites. With this
   addition, the generator emits 3 DiscordBot-specific interceptors
   (DiscordWizardMessenger, DiscordWizardInteractionModule,
   DiscordPermissionLookup) and the AssemblyLoad ships with the right
   GmRelay.DiscordBot.generated.cs.

Also expanded the AOT shape regression tests to cover all 4
CommandDefinition sites + added a 'containingClass' parameter to
ExtractMethodBody to disambiguate the duplicated LoadClubsAsync names
in DiscordWizardInteractionModule.

Bumps: 3.9.1 -> 3.9.2.
2026-06-08 10:48:24 +03:00
Toutsu 065e8011ee ci: pin Trivy v0.71.0 in install step
Deploy Telegram Bot / build-and-push (push) Successful in 42s
Deploy Telegram Bot / scan-images (push) Successful in 2m59s
Deploy Telegram Bot / deploy (push) Successful in 46s
The previous 'curl ... | sh -s -- -b /usr/local/bin' call passed no
positional tag, so the install script fell back to the GitHub 'latest'
tag. aquasecurity/trivy no longer publishes a 'latest' release tag, so
the CI failed at 'Install Trivy' with:
  aquasecurity/trivy crit unable to find '' - use 'latest' or see ...

This blocked the entire 3.9.1 hotfix deploy: build-and-push succeeded
(3 fresh 3.9.1 images pushed to git.codeanddice.ru), but scan-images
never ran and deploy was skipped. Production still runs 3.9.0 with the
broken wizard.

Pass 'v0.71.0' as the positional tag; v0.71.0 has Linux-ARM64 and
Linux-AMD64 builds so both the deploy runner (RPi 5) and pr-checks
runner pick the right tarball.
2026-06-08 10:23:31 +03:00
Toutsu f796b7d1e4 fix(shared): make WizardDraftRepository AOT-safe (v3.9.1 hotfix)
Deploy Telegram Bot / build-and-push (push) Successful in 7m24s
Deploy Telegram Bot / scan-images (push) Failing after 4s
Deploy Telegram Bot / deploy (push) Has been skipped
Production regression in 3.9.0: Telegram bot silently dropped every update.
WizardDraftRepository.GetActiveAsync was called on every Telegram update
(via UpdateRouter -> TryGetWizardContext) and threw
System.PlatformNotSupportedException in NativeAOT, because Dapper.AOT 1.0.48
only generates interceptors for the (sql, object?) extension overloads and
NOT for the (CommandDefinition) overload. The runtime then fell back to
Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit and
fails on AOT. TelegramBotService swallowed the exception, so /newsession
appeared to start but no button press reached the wizard and no session was
created.

Two related changes in WizardDraft:

1. Switched WizardDraftRepository.* from 'new CommandDefinition(sql, params,
   cancellationToken: ct)' to the direct 'connection.Query*(sql, params)'
   overload, matching the working pattern in JoinSessionHandler. Dapper.AOT
   now generates CommandFactory30<WizardDraft> + RowFactory17<WizardDraft> +
   QuerySingleOrDefaultAsync37<WizardDraft> for all four methods.

2. WizardDraft.CreatedAt/UpdatedAt/ExpiresAt are now DateTime (UTC) instead
   of DateTimeOffset. AOT RowFactory calls reader.GetDateTime() directly and
   does not perform DateTime -> DateTimeOffset conversion; the previous type
   raised InvalidCastException on the very first wizard_drafts query.

All 588/590 tests pass (2 pre-existing skipped, +5 new AOT regression tests
in WizardDraftRepositoryAotShapeTests). dotnet format clean.

Bumps: 3.9.0 -> 3.9.1.

Note: GetOwnerClubsAsync (Telegram/Discord), DiscordPermissionLookup, and
DiscordWizardInteractionModule.GetOwnerClubsAsync still use CommandDefinition
and will hit the same Reflection.Emit AOT failure when the user reaches the
PickClub visibility step. Follow-up in 3.9.2.
2026-06-08 10:02:59 +03:00
Toutsu 415c13bf00 Merge pull request 'feat(discord): step-by-step game/pool creation wizard (issue #112)' (#122) from feat/issue-112-wizard-refactor into main
Deploy Telegram Bot / build-and-push (push) Successful in 6m52s
Deploy Telegram Bot / scan-images (push) Successful in 2m32s
Deploy Telegram Bot / deploy (push) Successful in 41s
Reviewed-on: #122
2026-06-06 08:04:16 +03:00
Toutsu 85ff3a7faf fix(discord): address code-review findings on wizard adapter (issue #112)
PR Checks / test-and-build (pull_request) Successful in 9m54s
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 -
2026-06-05 23:09:24 +03:00
Toutsu d034d6acb9 @chore(release): bump version to 3.9.0 (issue #112)
- deploy.yml: VERSION 3.8.0 -> 3.9.0 (docker image tag for next push to main)
- NavMenu.razor: visible version v3.8.0 -> v3.9.0
- CampaignTemplatesNavigationTests.NavMenu_ShouldExposeCurrentProjectVersion: expected v3.7.1 -> v3.9.0 (was broken since 3.8.0 bump in commit 71080ae)
- RELEASE_NOTES.md: prepend Minor 3.9.0 entry (Discord wizard, issue #112) with full file inventory
- docs/review-brief.md: code-review spec for the verifier session

Build green (0 warnings, 0 errors), dotnet format clean.
2026-06-05 22:48:49 +03:00
Toutsu c4a77d3d73 docs: mark club-lookup + modal-popup as fixed in deliverable 2026-06-05 19:11:28 +03:00
Toutsu 7cfb1968c0 fix(discord): fix select/modal parser off-by-one + wire real club lookup
The dispatcher rejected every valid select menu and modal submit
because of an off-by-one in the customId parts-length check
(NetCord strips the matching 'wizard' prefix and passes the
remainder, so 'wizard:select:Visibility' arrives as
'select:Visibility' = 2 parts, not 3).

Also fixed: WizardClubLookup.LoadClubsAsync returned an empty
list, making the PickClub step always show 'no clubs'. Now
queries the DB via NpgsqlDataSource with the same Owner|CoGm
role filter the messenger uses.

Build green, 190/192 wizard+Discord tests pass (2 pre-existing
skips), format clean.
2026-06-05 19:10:20 +03:00
Coder b1bd47f6c1 fix(discord): open modal popup after wizard state advance
The previous commit (f095209) shipped a DiscordWizardInteractionModule
whose MaybeOpenModalAsync helper was a documented no-op: the handler
called SendResponseAsync(DeferredMessage) early, then tried to swap
the deferred response for a Modal via ModifyResponseAsync, which
NetCord forbids (the response type is locked after the first call).
As a result, the wizard's button click that advances to a text-input
step (Title, Description, Cover, DateTime, Capacity, PoolSlot*…)
edited the draft embed but never popped the modal, leaving the user
stuck on a step that demanded popup input.

This commit restructures the dispatcher:
- Run the wizard FIRST (a separate REST call to edit the draft embed;
  no interaction response is touched yet).
- Then send the interaction response as either
  InteractionCallback.Modal(modalProperties) when the new step is in
  the OpenModal set (Title, Description, Cover, DateTime, Capacity,
  PoolSlotDateTime, PoolSlotCapacity, SystemFreeText,
  DurationFreeText, PoolSystemDurationFreeText), or
  InteractionCallback.DeferredMessage(MessageFlags.Ephemeral) otherwise.
- The Modal handler's draft.Step = wizardStep / originalStep restore
  hack is removed: the wizard's mutation of draft.Step must persist
  to the DB (the wizard already called _drafts.UpsertAsync before we
  get control back), so restoring locally would only mask the truth
  from the next interaction's GetActiveAsync.
- The Resume:continue path re-renders the current step via the
  messenger and acks the click with a deferred ephemeral.
- The Create path delegates to DiscordWizardSubmitter.SubmitAsync and
  acks the click with a deferred ephemeral.
- The constructor now assigns _messenger (was unassigned, caught by
  nullable-flow analysis).

Also adds deliverable.md in the repo root describing the full Discord
adapter for issue #112.

Build green. 190/190 Discord+Wizard tests pass (2 pre-existing skipped).
dotnet format clean. The previous 12 source-level smoke tests still
pass — they assert on file shape, not runtime flow.
2026-06-05 18:53:59 +03:00
Coder f0952096f3 feat(discord): wizard interaction handlers + DI for StringMenu/Modal (issue #112)
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.
2026-06-05 18:31:47 +03:00
Coder b81d865832 feat(discord): step-by-step game/pool creation wizard (issue #112)
Add Discord adapter for the platform-neutral wizard moved to Shared in
the previous commit. Six new files in src/GmRelay.DiscordBot/Features/
Sessions/Wizard/:

- DiscordWizardContextStore: IWizardContextStore abstraction +
  thread-safe in-memory impl keyed by draft id. Holds the (guild,
  channel, message) coordinates the messenger needs to re-send the
  draft after a 15-minute interaction token expires.
- DiscordWizardStep: renderer for all 15 wizard steps. Returns an
  embed plus an IReadOnlyList<IMessageComponentProperties> that mixes
  ActionRow buttons with StringMenu select menus. Also exposes
  BuildModal for the 8 modal-collecting steps.
- DiscordWizardMessenger: IWizardMessenger impl backed by NetCord's
  RestClient + NpgsqlDataSource. Edit falls back to re-send on
  401/403/404. Toast replies are stashed in the existing
  DiscordInteractionReplyCache.
- DiscordWizardSubmitter: 3-retry finalize loop. Builds the shared
  CreateSessionCommand and calls CreateSessionHandler; on success
  edits the message to "ok Created: N sessions", on failure shows
  retry/cancel buttons.
- DiscordWizardCommand: /newsession-wizard slash command with an
  optional mode param (single|pool). Owner/co-GM check via the
  shared group_managers table.
- DiscordPermissionLookup: small helper that loads DB manager ids
  for a guild.

Program.cs gets 5 new singleton registrations (IWizardDraftRepository,
IWizardContextStore, IWizardMessenger, GameCreationWizard,
DiscordWizardSubmitter). The slash command is auto-discovered by
AddApplicationCommands<SlashCommandInteraction, SlashCommandContext>()
+ AddModules(typeof(Program).Assembly).

Build green. All 85 wizard tests + 95 Discord tests pass.
dotnet format clean.

Open: DiscordWizardInteractionModule (button/modal handlers) is not
yet implemented; the bot starts and /newsession-wizard works to the
point of posting the first embed, but subsequent button clicks won't
be handled. A follow-up commit will add the component-interaction
module.
2026-06-05 17:52:29 +03:00
Toutsu 8f0f2ef7e7 refactor(wizard): move core to Shared, add IWizardMessenger contract (issue #112)
Moves the game-creation wizard state machine, view builder, and
platform-neutral contracts (callback data, step names, storage
exception, club option, step limits) from GmRelay.Bot to GmRelay.Shared.
Telegram continues to work through a new TelegramWizardMessenger
implementing IWizardMessenger and a WizardInteractionMapper that
converts Update → WizardInteraction. Wires the new platform column on
wizard_drafts (V032 migration) and switches chat/owner/thread/message
ids to TEXT so the same table can hold Discord snowflakes later.

- GameCreationWizard: now in Shared, takes IWizardMessenger +
  IWizardDraftRepository, dispatches on WizardInteraction.
- New IWizardMessenger contract with Edit/Send/Answer/GetOwnerClubs
  (returns string ids so Telegram longs and Discord snowflakes both
  fit).
- New WizardStepViewBuilder in Shared returns
  (text, IReadOnlyList<WizardAction>); TelegramWizardMessenger
  renders actions into InlineKeyboardMarkup via a new Bot-side
  ToInlineKeyboard helper.
- New WizardInteractionMapper in Bot (5-case test) converts Telegram
  Update to WizardInteraction.
- WizardDraft gains a Platform column; ChatId/MessageThreadId/OwnerId/
  DraftMessageId switched to string. V032 migrates existing rows and
  rebuilds the owner lookup index on (platform, owner_id).
- All existing wizard / create-session tests updated to the new
  contract (HandleInteractionAsync + WizardInteraction). Wizard
  callback-data format preserved.
- dotnet build clean, dotnet format --verify-no-changes clean, all
  101 wizard tests pass.
2026-06-05 16:23:20 +03:00
Toutsu 71080aeab6 @chore(release): bump version to 3.8.0 (issue #111)
Deploy Telegram Bot / build-and-push (push) Successful in 5m3s
Deploy Telegram Bot / scan-images (push) Successful in 1m40s
Deploy Telegram Bot / deploy (push) Successful in 37s
Game-creation wizard: replace /newsession text template with step-by-step
inline-keyboard flow for single games and game pools.

- Directory.Build.props: 3.7.1 -> 3.8.0
- compose.yaml: pin bot, discord-bot and web images to 3.8.0
- deploy.yml VERSION env: 3.7.1 -> 3.8.0
- NavMenu.razor: nav-version 3.7.1 -> 3.8.0

🤖 Generated with Claude Code
@
2026-06-04 15:49:01 +03:00
Toutsu a843c8b278 style: dotnet format pass on wizard code
PR Checks / test-and-build (pull_request) Successful in 8m12s
Deploy Telegram Bot / build-and-push (push) Successful in 5m37s
Deploy Telegram Bot / scan-images (push) Successful in 1m34s
Deploy Telegram Bot / deploy (push) Successful in 42s
2026-06-04 15:33:25 +03:00
Toutsu 186492a18d test(wizard): add submit, cleanup, router delegation tests 2026-06-04 10:33:50 +03:00
Toutsu 2819786f91 test(wizard): add wizard tests + refactor to IWizardDraftRepository
- Extract IWizardDraftRepository interface for testability (NSubstitute cannot
  mock sealed classes; the codebase uses fake-style doubles instead).
- Add step-transition, pool-slot, validation, cancel/back, and render-shape tests
  using FakeWizardDraftRepository and FakeWizardMessenger.
- Fix wizard payload persistence bug: HandleCallbackAsync and HandleTextAsync
  now call SavePayload after ApplyChoice/ApplyText mutations, so subsequent
  LoadPayload calls see the user's progress. Previously, local WizardPayload
  mutations were discarded and the wizard reset on every step.
- CommitCurrentPoolSlot now auto-creates a slot via EnsureCurrentPoolSlot when
  one is missing, so the PoolSlotCapacity → waitlist click is recoverable
  even if the user lands on the step without a slot.
2026-06-04 09:53:15 +03:00
Toutsu 8c1bda73ed feat(wizard): register wizard services in Program.cs DI 2026-06-04 09:18:16 +03:00
Toutsu af345ba765 feat(wizard): delegate updates to wizard when an active draft exists 2026-06-04 09:14:13 +03:00
Toutsu 4a04d7d723 refactor(wizard): make CreateSessionHandler wizard-driven and remove legacy parser 2026-06-04 09:00:37 +03:00
Toutsu eeffae659f feat(wizard): add WizardDraftCleanupService (1-min tick) 2026-06-04 08:44:57 +03:00
Toutsu ea567a36ee feat(wizard): add GameCreationWizard state-machine service 2026-06-04 08:42:43 +03:00
Toutsu be86a2a08a feat(wizard): add WizardStep renderer (single + pool steps) 2026-06-04 08:33:53 +03:00
Toutsu 1b49211085 feat(wizard): add WizardStorageException 2026-06-04 08:30:50 +03:00
Toutsu 96a4807002 feat(wizard): add ITelegramWizardMessenger (edit/send/answer/club-list) 2026-06-04 08:28:30 +03:00
Toutsu cff4e48b57 feat(wizard): add step name and callback data constants 2026-06-04 08:18:15 +03:00
Toutsu 384887a862 test(wizard): add WizardDraftRepository integration tests 2026-06-04 08:13:22 +03:00
Toutsu 4d2aef637f fix(wizard): bind @PayloadJson parameter in UpsertAsync INSERT
The UpsertAsync SQL used @Payload (without 'Json' suffix) but the
WizardDraft POCO exposes the property as PayloadJson. Dapper.AOT
requires parameter names to match property names, so the parameter
went through unbinded and PostgreSQL rejected 'payload' as a column
reference. Without integration tests this went unnoticed; the new
WizardDraftRepositoryTests now exercise the path and surface it.
2026-06-04 08:13:10 +03:00
Toutsu c45c46abcf feat(wizard): add WizardDraftRepository (Dapper.AOT) 2026-06-04 08:01:46 +03:00
Toutsu 2c7495cd8d feat(wizard): add WizardPayload with AOT JSON source-gen 2026-06-04 07:59:13 +03:00
Toutsu d5fdc19016 feat(wizard): add WizardDraft POCO 2026-06-04 07:56:38 +03:00
Toutsu 10410d758c feat(db): add wizard_drafts table (V031) 2026-06-04 07:54:43 +03:00
Toutsu 771ff9be34 Merge pull request #120: fix(web): include PublicationMode/IsMembersOnly in showcase SQL (v3.7.1)
Deploy Telegram Bot / build-and-push (push) Successful in 5m11s
Deploy Telegram Bot / scan-images (push) Successful in 1m30s
Deploy Telegram Bot / deploy (push) Successful in 38s
2026-06-03 22:31:17 +03:00
Toutsu 29f6f6a827 fix(web): include PublicationMode/IsMembersOnly in showcase SQL to fix /showcase 500
PR Checks / test-and-build (pull_request) Successful in 8m17s
Dapper.AOT generated a 19-parameter ctor for ShowcaseSessionRow based on the
SELECT list in GetShowcaseSessionsAsync / GetShowcaseSessionAsync. After
adding PublicationMode and IsMembersOnly to ShowcaseSessionDto in v3.7.0 the
record itself was extended, but the SELECT still returned 19 columns, so the
materializer threw "A parameterless default constructor or one matching
signature (...) is required" and every request to /showcase returned 500.

Add s.publication_mode and (s.publication_mode = 'ClubOnly') to both SELECT
lists and propagate them through the ShowcaseSessionDto construction. The
field list now matches the generated constructor exactly.

Version bump 3.7.0 -> 3.7.1 (patch).
2026-06-03 22:21:31 +03:00
Toutsu 6951c72f3c Merge pull request #119: feat(web): private club showcases with membership flow (v3.7.0, issue #110)
Deploy Telegram Bot / build-and-push (push) Successful in 5m29s
Deploy Telegram Bot / scan-images (push) Successful in 1m29s
Deploy Telegram Bot / deploy (push) Successful in 39s
2026-06-03 11:46:01 +03:00
Toutsu 22e9859fdf fix(web): allow cancelling pending applications; drop contradictory message guard
PR Checks / test-and-build (pull_request) Successful in 7m50s
Address review feedback from PR #119:

- LeaveClubMembershipAsync: was rejecting Pending rows because the SQL
  required status = 'Active', so clicking "Отозвать заявку" on a Pending
  membership surfaced a misleading "Active membership X not found"
  InvalidOperationException. Now the method first tries Active -> Left
  and falls back to Pending -> Rejected so the same UI flow covers both
  states.
- PublicClub.razor TrySubmitApplicationAsync: removed the empty-input
  guard that contradicted the "(необязательно)" label and the server
  side (AuthorizedMembershipService already trims and accepts null).

No tests broken (493 still passing), no public-API changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:33:28 +03:00
Toutsu 6cb2fbe610 feat(web): add private club showcases with membership flow (v3.7.0)
PR Checks / test-and-build (pull_request) Successful in 7m28s
Implements Issue #110: game masters can now publish sessions
exclusively to a club's private showcase, gated behind a
member application and approval flow. Adds a 4-state
publication_mode (None/Catalog/ClubOnly/Both) replacing the
binary is_public, plus a club_memberships table with
Pending/Active/Rejected/Left lifecycle and partial unique
index ensuring a single Active row per (group, player).

Highlights
- V030 migration: club_memberships, publication_mode, drop
  is_public, recreate partial indexes, portfolio_games gains
  publication_mode.
- PublicationMode enum + extensions in GmRelay.Shared.
- ISessionStore gains 12 membership/showcase methods;
  AuthorizedMembershipService owns the membership flow with
  GM-only approve/reject authorization.
- PublicClub / PublicMasterProfile / PublicSession: member-
  aware queries (ClubOnly visible only to Active members).
- New pages: MyClubMemberships (/profile/memberships) and
  ClubApplications (/group/{id}/applications).
- GroupDetails and EditSession switch from a bool toggle to
  a 4-state publication_mode selector.
- NavMenu adds Moji kluby, PublicLayout adds Kluby.

Tests: 4 new test files (PublicationMode, ClubMemberships,
AuthorizedMembershipService, ClubShowcaseSource) + updates
to PublicClubPages, AuthorizedSessionService/Portfolio
service FakeSessionStore, CampaignTemplatesNavigation.
493 tests pass.

Bump version 3.6.0 -> 3.7.0 across Directory.Build.props,
compose.yaml, deploy.yml, NavMenu.razor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:09:22 +03:00
Toutsu 992f71c0e4 Merge pull request 'feat(web): add completed-game portfolio to GM showcase (issue #108)' (#118) from codex/feature-issue-108-portfolio into main
Deploy Telegram Bot / build-and-push (push) Successful in 5m36s
Deploy Telegram Bot / scan-images (push) Successful in 1m44s
Deploy Telegram Bot / deploy (push) Successful in 39s
Reviewed-on: #118
2026-06-02 18:28:48 +03:00
Toutsu 21e29564f6 docs: document portfolio release and bump version to 3.6.0
PR Checks / test-and-build (pull_request) Successful in 8m32s
2026-06-02 16:07:01 +03:00
Claude 401653a4d1 feat(web): publish completed game portfolios 2026-06-02 15:41:43 +03:00
Toutsu e970e94e00 feat(web): add portfolio management UI 2026-06-02 15:21:51 +03:00
Claude 242ff99a83 feat(web): authorize portfolio management and reviews 2026-06-02 15:01:29 +03:00
Toutsu f2c9f34ab4 feat(web): add portfolio persistence 2026-06-02 14:46:57 +03:00
Toutsu e5945288ac feat(web): add local portfolio cover storage 2026-06-02 12:35:00 +03:00
Toutsu 7d1489445e feat(web): define portfolio contracts and validation 2026-06-02 12:21:55 +03:00
Toutsu 4af4e52778 docs: sync portfolio task 1 review index 2026-06-02 10:32:34 +03:00
Toutsu a20da4b1a0 fix(data): serialize portfolio mutations before rows 2026-06-02 10:32:13 +03:00
Toutsu edf40c9a09 docs: sync portfolio task 1 review index 2026-06-02 07:57:46 +03:00
Toutsu 1a8161027c fix(data): reject stale reschedule snapshots 2026-06-02 07:57:30 +03:00
Toutsu 85918c1e5d docs: sync portfolio task 1 review index 2026-06-02 07:31:54 +03:00
Toutsu ea714480d3 fix(data): serialize new-link publication races 2026-06-02 07:31:35 +03:00
Toutsu 1d62f69ff0 fix(data): lock racing portfolio publications 2026-06-02 07:10:37 +03:00
Toutsu d762ecc377 fix(data): serialize portfolio future reschedules 2026-06-01 20:58:53 +03:00
Toutsu a28b75dd5b fix(data): align portfolio mutation lock order 2026-06-01 20:23:43 +03:00
Toutsu 2b725708ef test(discord): keep Moscow time parsing fixture in future 2026-06-01 20:00:59 +03:00
Toutsu da0a306340 fix(data): enforce completed portfolio sessions 2026-06-01 15:04:20 +03:00
Toutsu f493836b77 fix(data): reject stale portfolio trigger snapshots 2026-06-01 14:39:04 +03:00
Toutsu 6e7a0cb493 fix(data): enforce portfolio validation isolation 2026-06-01 14:28:51 +03:00
Toutsu 76b3ff7ddf fix(data): serialize portfolio publication validation 2026-06-01 14:12:29 +03:00
Toutsu 536061f63c docs: sync portfolio task 1 review indexes 2026-06-01 10:04:44 +03:00
Toutsu f7a12d14d2 docs: document portfolio concurrency hardening plan 2026-06-01 09:56:33 +03:00
Toutsu 3c1a98bcc4 fix(data): harden portfolio publication concurrency 2026-06-01 09:46:18 +03:00
Toutsu d591e5ed5a fix(data): protect portfolio publication invariant 2026-06-01 09:20:27 +03:00
Toutsu 5809a470b9 test(data): scope portfolio migration assertions 2026-06-01 09:07:47 +03:00
Toutsu ed842d2195 test(data): harden portfolio migration contract 2026-05-30 23:37:40 +03:00
Toutsu a0040ec9fb test(data): tighten portfolio moderation schema assertion 2026-05-30 23:25:12 +03:00
Toutsu 67b8aafd97 feat(data): add completed game portfolio schema 2026-05-30 23:21:31 +03:00
Toutsu ac417731d6 docs: plan completed game portfolio implementation 2026-05-30 21:36:05 +03:00
Toutsu 991c7e1965 docs: specify completed game portfolio 2026-05-30 14:16:12 +03:00
Toutsu 0d9df29f58 Merge pull request #116: feat(web): redesign profile page to match design system
Deploy Telegram Bot / build-and-push (push) Successful in 6m5s
Deploy Telegram Bot / scan-images (push) Successful in 3m44s
Deploy Telegram Bot / deploy (push) Successful in 33s
2026-05-29 15:19:45 +03:00
Toutsu d54950698a feat(web): redesign profile page to match design system
PR Checks / test-and-build (pull_request) Successful in 12m8s
- Rewrite Profile.razor to use .page-container, .glass-card, .gm-alert,

  .btn-gm, .status-badge, .empty-state and other standard design system classes

- Replace custom unstyled markup with breadcrumb, page-header, skeleton loaders

- Add .identity-list styles to app.css for linked accounts section

- Unify visual language with Home, Templates and GroupDetails pages
2026-05-29 15:15:48 +03:00
Toutsu 394bd19b95 Merge pull request #114: fix(web): restore public game pages
Deploy Telegram Bot / build-and-push (push) Successful in 6m0s
Deploy Telegram Bot / scan-images (push) Successful in 3m30s
Deploy Telegram Bot / deploy (push) Successful in 30s
2026-05-29 09:40:20 +03:00
Toutsu b52d4000b4 fix(web): restore public game pages
PR Checks / test-and-build (pull_request) Successful in 11m56s
Use the existing group_managers.created_at column when picking owner profile links for public pages.

Bump version -> 3.5.1
2026-05-29 09:27:01 +03:00
Toutsu b32f962f11 Merge pull request #113: feat(web): add public master profiles
Deploy Telegram Bot / build-and-push (push) Successful in 6m20s
Deploy Telegram Bot / scan-images (push) Successful in 3m0s
Deploy Telegram Bot / deploy (push) Successful in 28s
2026-05-29 00:22:43 +03:00
Toutsu 0c1d3abd7e feat(web): add public master profiles
PR Checks / test-and-build (pull_request) Successful in 12m32s
Add sanitized public GM profiles with publication controls, public /gm/{slug} pages, and links from public game surfaces.

Bump version -> 3.5.0
2026-05-29 00:08:14 +03:00
Toutsu d81564c308 Merge pull request #109: feat: добавить каталог игр и витрину ваншотов (issue #39)
Deploy Telegram Bot / build-and-push (push) Successful in 6m52s
Deploy Telegram Bot / scan-images (push) Successful in 3m21s
Deploy Telegram Bot / deploy (push) Successful in 33s
2026-05-28 17:56:53 +03:00
Toutsu accb3b2405 fix(web): правильный парсинг query string для ?register=1
PR Checks / test-and-build (pull_request) Successful in 12m50s
Заменен Navigation.Uri.Contains() на QueryHelpers.ParseQuery
для корректного определения параметра register без ложных
срабатываний на подстроки (например, register=10).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:28:51 +03:00
Toutsu a63e3bef1e fix: финальные правки ревью для issue #39
PR Checks / test-and-build (pull_request) Successful in 13m16s
- 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>
2026-05-28 17:00:33 +03:00
Toutsu 9d9aca53df chore: bump version to 3.4.0 2026-05-28 16:43:11 +03:00
Toutsu 5b6971fda5 test: consolidate capacity tests, add GameSystem edge cases, remove ShowcaseQueryTests 2026-05-28 16:40:11 +03:00
Toutsu b496a401fc test: add GameSystem fuzzy matching and showcase query tests
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:33:29 +03:00
Toutsu 76c6818952 fix(shared): add missing Platform parameter in players upsert 2026-05-28 16:28:25 +03:00
Toutsu 633a020212 fix(shared): convert GameSystem to string in SQL, guard rollback after commit 2026-05-28 16:26:54 +03:00
Toutsu ab38238fe8 feat(shared): extend CreateSessionCommand with showcase metadata
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:20:44 +03:00
Toutsu 4145cacc52 fix(web): add session-description styles for public session detail 2026-05-28 16:18:58 +03:00
Toutsu 6d59737d07 feat(web): update public session detail with showcase fields
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:13:35 +03:00
Toutsu 71ffcce06b fix(web): add try/finally, concurrency guard, accessible label, registration link to showcase 2026-05-28 16:07:09 +03:00
Toutsu 72f43dbef2 feat(web): add /showcase catalog page with filters
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:00:21 +03:00
Toutsu a5f4a68c6a fix(web): add public-session guards and ON CONFLICT to RegisterFromShowcaseAsync 2026-05-28 15:40:21 +03:00
Toutsu b2497ed877 feat(web): add showcase query and registration methods 2026-05-28 15:27:18 +03:00
Toutsu 9b42ea034a fix(shared): align GameSystem enum with spec, make TryParseFuzzy nullable and display-name based 2026-05-28 15:06:39 +03:00
Toutsu f94bea3e74 feat(shared): add GameSystem enum and Showcase DTOs
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:58:49 +03:00
Toutsu cde1e4311f feat(db): V027 add showcase fields to sessions 2026-05-28 14:46:04 +03:00
Toutsu 847a40815f docs: add implementation plan for issue #39 2026-05-28 14:41:12 +03:00
Toutsu 6fd03ef836 docs: add design spec for issue #39 game catalog and showcase 2026-05-28 14:31:17 +03:00
Toutsu c2ccc35e50 Merge pull request #107: feat: add public club pages
Deploy Telegram Bot / build-and-push (push) Successful in 6m53s
Deploy Telegram Bot / scan-images (push) Successful in 3m39s
Deploy Telegram Bot / deploy (push) Successful in 33s
2026-05-28 12:38:33 +03:00
Toutsu 3418d1a46c feat: add public club pages
PR Checks / test-and-build (pull_request) Successful in 12m47s
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
2026-05-28 12:23:47 +03:00
Toutsu fac5d75c7e Fix Discord co-GM management
Deploy Telegram Bot / build-and-push (push) Successful in 5m58s
Deploy Telegram Bot / scan-images (push) Successful in 3m39s
Deploy Telegram Bot / deploy (push) Successful in 34s
2026-05-27 16:32:47 +03:00
Toutsu 7a2965b43f fix(bot): add missing DI registrations for shared DeleteSessionHandler and ListSessionsHandler
Deploy Telegram Bot / build-and-push (push) Successful in 6m39s
Deploy Telegram Bot / scan-images (push) Successful in 3m26s
Deploy Telegram Bot / deploy (push) Successful in 29s
PR #106 extracted DeleteSessionHandler and ListSessionsHandler to GmRelay.Shared,
but forgot to register the shared implementations in Program.cs. This caused
an InvalidOperationException at startup on Native AOT builds because the Bot
wrappers could not resolve their shared dependencies.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:58:51 +03:00
Toutsu a0df94fc91 Merge branch 'main' of ssh://git.codeanddice.ru:222/Toutsu/GmRelayBot
Deploy Telegram Bot / build-and-push (push) Successful in 7m11s
Deploy Telegram Bot / scan-images (push) Successful in 3m27s
Deploy Telegram Bot / deploy (push) Failing after 1m3s
2026-05-27 15:19:32 +03:00
Toutsu 79694f7de8 Merge pull request #106: refactor: extract remaining Telegram handlers to platform-neutral contracts 2026-05-27 15:19:23 +03:00
Toutsu 542f15f2d6 refactor: extract remaining Telegram handlers to platform-neutral contracts
PR Checks / test-and-build (pull_request) Successful in 13m48s
- Extract CreateSessionHandler, ListSessionsHandler, DeleteSessionHandler,
  ExportCalendarHandler, HandleRescheduleTimeInputHandler,
  HandleRescheduleVoteHandler to GmRelay.Shared
- Add IPlatformMessenger methods: SendScheduleAsync, UpdateScheduleAsync,
  SendGroupMessageAsync with actions, CreateThreadAsync, DeleteThreadAsync
- Rewrite Telegram Bot wrappers as thin adapters delegating to shared handlers
- Rewrite DiscordRescheduleVoteHandler to use shared HandleRescheduleVoteHandler
- Update UpdateRouter with explicit type aliases for ambiguous handler names
- Add contract and source-inspection tests for extracted handlers
- Bump version 3.1.1 → 3.2.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:52:09 +03:00
Toutsu 64216f5a26 Merge pull request #105: fix template batch topics
Deploy Telegram Bot / build-and-push (push) Successful in 6m3s
Deploy Telegram Bot / scan-images (push) Successful in 3m25s
Deploy Telegram Bot / deploy (push) Successful in 29s
2026-05-27 14:05:38 +03:00
Toutsu 383e2c1d8d fix: create Telegram topics for template batches
PR Checks / test-and-build (pull_request) Successful in 12m56s
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
2026-05-27 13:50:18 +03:00
Toutsu bfa979a224 Merge pull request #104: refactor: завершить platform migration и удалить deprecated telegram_* scaffolding
Deploy Telegram Bot / build-and-push (push) Successful in 6m43s
Deploy Telegram Bot / scan-images (push) Successful in 3m25s
Deploy Telegram Bot / deploy (push) Successful in 30s
- Migrated all core domain SQL from telegram_* columns to platform + external_*
- Added V024/V025 migrations with backfill and deprecation comments
- Removed all COALESCE(external_*, telegram_*) fallbacks
- Replaced gm_telegram_id join with group_managers query in HandleRsvpHandler
- Updated Bot, Web, DiscordBot, and Shared handlers
- Bumped version to 3.1.0

CI run #265 passed (289 tests, 0 warnings, 0 vulnerabilities)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 17:19:47 +03:00
Toutsu c69ebf6c03 test: update DiscordProjectStructureTests version asserts to 3.1.0
PR Checks / test-and-build (pull_request) Successful in 13m24s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 16:58:18 +03:00
Toutsu 040b0a3cdb refactor: завершить platform migration и удалить deprecated telegram_* scaffolding
PR Checks / test-and-build (pull_request) Failing after 13m15s
- Добавлены миграции V024 (backfill + deprecation comments + calendar_subscriptions platform identity) и V025 (backfill proposed_by_external_user_id)
- Все Bot handlers переведены с telegram_id/chat_id на platform + external_*
- Shared handlers очищены от COALESCE fallback с telegram_* колонками
- DiscordBot очищен от COALESCE fallback
- Web SessionService и CalendarSubscriptionService переведены на external_*
- HandleRsvpHandler: убран legacy UNION с gm_telegram_id, теперь только group_managers
- RescheduleVotingFinalizer: переведен на external_username/external_user_id
- Tests: добавлены asserts для V024/V025
- Версия обновлена до 3.1.0

Bump version → 3.1.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 16:41:15 +03:00
Toutsu a5aed14dd2 fix(discord): add backoff to scheduler to prevent 403 spam
Deploy Telegram Bot / build-and-push (push) Successful in 6m37s
Deploy Telegram Bot / scan-images (push) Successful in 3m45s
Deploy Telegram Bot / deploy (push) Successful in 33s
- SessionSchedulerService now backs off for 15 minutes after any
  handler failure (confirmation, one-hour reminder, join link),
  preventing infinite retry loops on Discord 403 Missing Access.
- Added per-session ConcurrentDictionary backoff tracking with
  automatic cleanup on success.
- Enhanced DiscordPlatformMessenger logging for SendConfirmation
  and SendJoinLink to aid permission diagnostics.
- Added 3 regression tests for backoff behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 15:51:25 +03:00
Toutsu 9fc434b42b fix(discord): treat /newsession and /reschedule input as Moscow time (UTC+3)
Deploy Telegram Bot / build-and-push (push) Successful in 6m15s
Deploy Telegram Bot / scan-images (push) Successful in 3m20s
Deploy Telegram Bot / deploy (push) Successful in 34s
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>
2026-05-26 15:12:00 +03:00
Toutsu c2cc7fd9a8 fix(web): show discord sessions and integration labels
Deploy Telegram Bot / build-and-push (push) Successful in 5m46s
Deploy Telegram Bot / scan-images (push) Successful in 3m29s
Deploy Telegram Bot / deploy (push) Successful in 29s
2026-05-26 14:43:33 +03:00
Toutsu 3447acd8c4 fix(discord): update sessions via interactions
Deploy Telegram Bot / build-and-push (push) Successful in 6m14s
Deploy Telegram Bot / scan-images (push) Successful in 3m12s
Deploy Telegram Bot / deploy (push) Successful in 31s
2026-05-26 14:24:06 +03:00
Toutsu 56aeca5288 fix(discord): sanitize embed join links
Deploy Telegram Bot / build-and-push (push) Successful in 5m53s
Deploy Telegram Bot / scan-images (push) Successful in 3m6s
Deploy Telegram Bot / deploy (push) Successful in 29s
2026-05-26 13:57:11 +03:00
Toutsu 6ed0a120a0 fix(discord): avoid duplicate schedule send after new session
Deploy Telegram Bot / build-and-push (push) Successful in 6m0s
Deploy Telegram Bot / scan-images (push) Successful in 3m22s
Deploy Telegram Bot / deploy (push) Successful in 29s
2026-05-26 13:40:59 +03:00
Toutsu 682dd3fdec Merge pull request #103: fix(db): make legacy telegram_* columns nullable for Discord multi-platform
Deploy Telegram Bot / build-and-push (push) Successful in 13m30s
Deploy Telegram Bot / scan-images (push) Successful in 3m35s
Deploy Telegram Bot / deploy (push) Successful in 33s
2026-05-26 13:10:58 +03:00
Toutsu c955e1572f fix(db): make legacy telegram_* columns nullable for Discord multi-platform
PR Checks / test-and-build (pull_request) Successful in 18m38s
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>
2026-05-26 12:51:45 +03:00
Toutsu a9aa84af0f Merge pull request #102: fix(discord): add missing Dapper.AOT reference to DiscordBot project
Deploy Telegram Bot / build-and-push (push) Successful in 5m58s
Deploy Telegram Bot / scan-images (push) Successful in 3m19s
Deploy Telegram Bot / deploy (push) Successful in 35s
fix(discord): add missing Dapper.AOT reference to DiscordBot project

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:32:26 +03:00
Toutsu dcbd9bab41 fix(discord): add missing Dapper.AOT reference to DiscordBot project
PR Checks / test-and-build (pull_request) Successful in 11m4s
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>
2026-05-26 12:20:56 +03:00
Toutsu 92d5d9c2d3 Merge pull request #101: fix(discord): add console logging and deferred responses
Deploy Telegram Bot / build-and-push (push) Successful in 5m56s
Deploy Telegram Bot / scan-images (push) Successful in 3m3s
Deploy Telegram Bot / deploy (push) Successful in 31s
fix(discord): add console logging and deferred responses

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:46:55 +03:00
Toutsu 47d106e288 fix(tests): update DiscordNewSessionHandlerTests for deferred response pattern
PR Checks / test-and-build (pull_request) Successful in 11m55s
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>
2026-05-26 11:33:03 +03:00
Toutsu a5624897e9 fix(discord): add console logging and deferred responses
PR Checks / test-and-build (pull_request) Failing after 12m3s
- 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>
2026-05-26 11:18:09 +03:00
Toutsu 11e75d036a Merge pull request #100: fix(discord): use GuildInteractionUser.Permissions instead of REST guild lookup
Deploy Telegram Bot / build-and-push (push) Successful in 5m52s
Deploy Telegram Bot / scan-images (push) Successful in 3m11s
Deploy Telegram Bot / deploy (push) Successful in 30s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 10:57:02 +03:00
Toutsu 2942da0c35 fix(discord): use GuildInteractionUser.Permissions instead of REST guild lookup
PR Checks / test-and-build (pull_request) Successful in 11m25s
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>
2026-05-26 10:44:59 +03:00
Toutsu 549c0c96ae Merge pull request #99: fix(discord): cast COUNT to int for slash command list query
Deploy Telegram Bot / build-and-push (push) Successful in 5m27s
Deploy Telegram Bot / scan-images (push) Successful in 2m49s
Deploy Telegram Bot / deploy (push) Successful in 31s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 10:23:05 +03:00
Toutsu dd9337dd20 fix(discord): cast COUNT to int for slash command list query
PR Checks / test-and-build (pull_request) Successful in 9m34s
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>
2026-05-26 10:10:13 +03:00
Toutsu 3cc3b373e5 Merge pull request #98: fix(discord): resolve slash commands from interaction payload instead of gateway cache
Deploy Telegram Bot / build-and-push (push) Successful in 4m59s
Deploy Telegram Bot / scan-images (push) Successful in 2m20s
Deploy Telegram Bot / deploy (push) Successful in 28s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:12:15 +03:00
Toutsu f6d5281af8 fix(discord): resolve slash commands from interaction payload instead of gateway cache
PR Checks / test-and-build (pull_request) Successful in 8m46s
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>
2026-05-25 18:01:53 +03:00
Toutsu fa63886195 Merge pull request #97: fix(discord): use correct slash command context type in AddApplicationCommands
Deploy Telegram Bot / build-and-push (push) Successful in 5m1s
Deploy Telegram Bot / scan-images (push) Successful in 2m22s
Deploy Telegram Bot / deploy (push) Successful in 28s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:23:32 +03:00
Toutsu 9bd5fe75c9 test: sync version assertions to 3.0.4
PR Checks / test-and-build (pull_request) Successful in 8m35s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:08:01 +03:00
Toutsu d931da37ec fix(discord): use correct slash command context type in AddApplicationCommands
PR Checks / test-and-build (pull_request) Failing after 8m7s
The default AddApplicationCommands() registers ApplicationCommandService<ApplicationCommandContext>,
but our modules inherit ApplicationCommandModule<SlashCommandContext>. Because SlashCommandContext
does not inherit from ApplicationCommandContext in NetCord, AddModules(typeof(Program).Assembly)
failed to discover the modules, so /newsession, /listsessions, /reschedule were never published
to Discord. Only /ping worked because it uses the minimal API route.

Fix: specify AddApplicationCommands<SlashCommandInteraction, SlashCommandContext>() so the
service matches the module context type, allowing module discovery to succeed.

Bump version to 3.0.4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:05:51 +03:00
Toutsu 9375fa45b2 Merge pull request #96: fix(discord): declare slash commands on module methods
Deploy Telegram Bot / build-and-push (push) Successful in 4m47s
Deploy Telegram Bot / scan-images (push) Successful in 2m9s
Deploy Telegram Bot / deploy (push) Successful in 27s
2026-05-25 16:37:15 +03:00
Toutsu 0b45aee96d fix(discord): declare slash commands on module methods
PR Checks / test-and-build (pull_request) Successful in 8m26s
2026-05-25 16:27:29 +03:00
Toutsu 80e346d6b5 Merge pull request #95: fix(discord): register slash command modules
Deploy Telegram Bot / build-and-push (push) Successful in 4m53s
Deploy Telegram Bot / scan-images (push) Successful in 2m12s
Deploy Telegram Bot / deploy (push) Successful in 27s
2026-05-25 16:04:42 +03:00
Toutsu eff0128d29 fix(discord): register slash command modules
PR Checks / test-and-build (pull_request) Successful in 8m27s
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.
2026-05-25 15:49:36 +03:00
Toutsu 8214e052af bump: version 3.0.1
Deploy Telegram Bot / build-and-push (push) Successful in 4m55s
Deploy Telegram Bot / scan-images (push) Successful in 2m2s
Deploy Telegram Bot / deploy (push) Successful in 28s
Synchronize version across all files:
- Directory.Build.props → 3.0.1
- compose.yaml → gmrelay-bot/web/discord-bot:3.0.1
- deploy.yml → VERSION: 3.0.1
- NavMenu.razor → v3.0.1
- DiscordProjectStructureTests → 3.0.1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 15:34:25 +03:00
Toutsu 2a233b2b1e fix: ensure Telegram is always primary in identity links
Deploy Telegram Bot / build-and-push (push) Successful in 5m6s
Deploy Telegram Bot / scan-images (push) Successful in 1m59s
Deploy Telegram Bot / deploy (push) Successful in 29s
When a Discord user linked Telegram via the Telegram Login Widget,
LinkIdentityAsync incorrectly made Discord primary and Telegram
secondary. This broke access to all Telegram groups/sessions because
ResolveEffectivePlayerIdAsync returned the (empty) Discord primary.

- In /auth/telegram callback, swap LinkIdentityAsync args so Telegram
  is always treated as the current (primary) account.
- Add V022 migration to reverse any existing incorrectly-oriented
  player_links where Discord is primary and Telegram is secondary.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 15:19:08 +03:00
Toutsu 5e3028e470 fix: SameSite=Lax for auth cookie + bidirectional identity linking
Deploy Telegram Bot / build-and-push (push) Successful in 4m45s
Deploy Telegram Bot / scan-images (push) Successful in 2m7s
Deploy Telegram Bot / deploy (push) Successful in 28s
- Change cookie auth SameSite from Strict to Lax so Discord OAuth callback
can see existing Telegram auth session and perform linking instead of
creating a new standalone Discord session (root cause of broken linking).
- Add linking logic to /auth/telegram endpoint for Discord→Telegram linking.
- Add Telegram Login Widget in Profile.razor for Discord users.
- Add CookieAuthOptionsTests to verify Lax SameSite configuration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 14:58:25 +03:00
Toutsu 63193310f2 hotfix: fix Blazor circuit crash on Discord link + add missing avatar_url column
Deploy Telegram Bot / build-and-push (push) Successful in 4m53s
Deploy Telegram Bot / scan-images (push) Successful in 1m47s
Deploy Telegram Bot / deploy (push) Successful in 28s
- Replace @onclick button with plain <a href="/auth/discord"> to avoid
circuit disconnect from forceLoad navigation during event handlers.
- Add query param handling (?linked, ?link_error) in Profile.razor for
Discord callback feedback.
- Add V021 migration: ALTER TABLE players ADD COLUMN avatar_url.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 14:39:24 +03:00
Toutsu af37f3a8ec fix: Profile.razor use ISessionStore directly + forceLoad for Discord link
Deploy Telegram Bot / build-and-push (push) Successful in 4m38s
Deploy Telegram Bot / scan-images (push) Successful in 1m41s
Deploy Telegram Bot / deploy (push) Successful in 26s
- Replace HttpClient API calls with direct ISessionStore DI to avoid
  302 redirect from missing auth cookie in Blazor Server interactive mode
- Use NavigationManager.NavigateTo with forceLoad=true for Discord OAuth
  to bypass Blazor circuit navigation and trigger full HTTP request

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 14:20:26 +03:00
Toutsu 66228cf106 Merge pull request #93: feat: unify Telegram and Discord accounts via identity linking
Deploy Telegram Bot / build-and-push (push) Successful in 5m6s
Deploy Telegram Bot / scan-images (push) Successful in 1m48s
Deploy Telegram Bot / deploy (push) Successful in 26s
2026-05-25 14:07:33 +03:00
Toutsu 9c59240f48 fix: connection leak in UpsertDiscordUserAsync + false conflict in LinkIdentityAsync
PR Checks / test-and-build (pull_request) Successful in 7m25s
- UpsertDiscordUserAsync: restore await using on opened connection
- LinkIdentityAsync: compute effectiveCurrentPrimary before existingLink check
  to prevent false conflict when current user is a secondary identity

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 13:59:41 +03:00
Toutsu baa25f2e1e feat: unify Telegram and Discord accounts via identity linking
PR Checks / test-and-build (pull_request) Successful in 7m6s
- 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>
2026-05-25 13:51:10 +03:00
Toutsu 7a2ed808c4 fix: replace cookie-based Discord OAuth CSRF with server-side state store
Deploy Telegram Bot / build-and-push (push) Successful in 4m19s
Deploy Telegram Bot / scan-images (push) Successful in 1m24s
Deploy Telegram Bot / deploy (push) Successful in 11s
- Replace __DiscordOAuthState cookie (blocked by third-party cookie policies)
  with in-memory DiscordOAuthStateStore singleton
- State is created server-side and validated on callback, eliminating
  cross-site cookie transmission issues entirely
- Removed CryptographicOperations dependency from Program.cs

Bump version → 2.8.1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 13:18:23 +03:00
Toutsu dd0828a63d Merge pull request #92: fix Discord OAuth CSRF cookie SameSite
Deploy Telegram Bot / build-and-push (push) Successful in 4m18s
Deploy Telegram Bot / scan-images (push) Successful in 1m28s
Deploy Telegram Bot / deploy (push) Successful in 34s
2026-05-25 13:08:31 +03:00
Toutsu 72a392e652 fix: Discord OAuth CSRF cookie SameSite=None for cross-site callback
PR Checks / test-and-build (pull_request) Successful in 6m34s
- 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>
2026-05-25 13:08:14 +03:00
Toutsu e1fac04775 Merge pull request #92: fix: add Discord OAuth token exchange logging for production diagnostics
Deploy Telegram Bot / build-and-push (push) Successful in 4m17s
Deploy Telegram Bot / scan-images (push) Successful in 1m24s
Deploy Telegram Bot / deploy (push) Successful in 23s
2026-05-25 12:47:19 +03:00
Toutsu 7e02e86cd6 fix: add Discord OAuth token exchange logging for production diagnostics
PR Checks / test-and-build (pull_request) Failing after 6m20s
- Log status code and response body when Discord /oauth2/token fails
- Helps identify why ExchangeCodeAsync returns null in production

Bump version → 2.8.1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 12:46:56 +03:00
Toutsu eb9a159dbb Merge pull request #91: feat: Discord OAuth и платформонезависимый Web Dashboard (issue #34)
Deploy Telegram Bot / build-and-push (push) Successful in 4m36s
Deploy Telegram Bot / scan-images (push) Successful in 1m22s
Deploy Telegram Bot / deploy (push) Successful in 26s
- Discord OAuth 2.0 login flow (identify + guilds scopes)
- Platform-agnostic auth via (platform, external_user_id)
- Cookie Authentication with ClaimsPrincipal
- Razor Pages updated to *ForCurrentUserAsync APIs
- CSRF protection for Discord OAuth
- V019 migration for audit log platform identity
- Version bumped to 2.8.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 12:14:27 +03:00
Toutsu 66dc53f12f fix(web): address PR review critical issues for Discord OAuth
PR Checks / test-and-build (pull_request) Successful in 6m6s
- Add V019 migration: rename session_audit_log.actor_telegram_id → actor_external_user_id
- Add CSRF protection to Discord OAuth flow (state cookie with HttpOnly/Secure/Strict)
- Add Discord OAuth env vars to compose.yaml, deploy.yml, and .env.example
- Fix SQL COALESCE for nullable telegram_id in GetGroupManagersAsync and GetSessionParticipantsAsync

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 12:07:40 +03:00
Toutsu 50f5307aac feat(web): finalize Discord OAuth and platform-agnostic auth
PR Checks / test-and-build (pull_request) Successful in 5m47s
- 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>
2026-05-25 11:47:54 +03:00
Toutsu 5fa7e26f72 test(web): add Discord auth and platform identity tests
- DiscordAuthServiceTests: authorize URL, token exchange, profile fetch
- PlatformIdentityTests: Telegram fallback, Discord identity, avatar URL

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:13:08 +03:00
Toutsu 976e204102 feat(web): add Discord login button, platform indicator, and CSS styles
- Discord login button on /login with brand colors
- NavMenu shows user avatar (Discord) and platform label
- CSS: login-divider, login-btn-discord, nav-user-info, nav-user-platform
- NavMenu version bumped to v2.8.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:12:16 +03:00
Toutsu 9d4256353d feat(web): refactor SessionStore and AuthorizedSessionService to platform-agnostic identity
- ISessionStore: all methods use (platform, external_user_id)
- SessionService: updated SQL queries and added UpsertDiscordUserAsync
- AuthorizedSessionService: resolves identity from HttpContext, no longer accepts telegram_id params
- SessionAccessDeniedException now accepts string externalUserId
- Added ExternalUserId/ExternalUsername to WebGroupManager and WebParticipant

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:08:10 +03:00
Toutsu 543fc42a6d feat(web): add platform-agnostic identity extraction from ClaimsPrincipal
- TryGetPlatformIdentity returns (platform, external_user_id)
- TryGetDiscordId for Discord-specific flows
- Backward-compatible fallback for legacy Telegram auth without Platform claim
- GetAvatarUrl helper for Discord avatars

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:02:29 +03:00
Toutsu bfed400b4d feat(web): add Discord OAuth service and authorization endpoints
- DiscordOAuthOptions for client_id, secret, redirect_uri
- DiscordAuthService exchanges code for token and fetches user profile
- /auth/discord and /auth/discord/callback endpoints
- CreateDiscordPrincipal for cookie auth claims
- Telegram principal now includes Platform claim for forward compatibility

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:02:13 +03:00
Toutsu d0ddf3fb58 fix(bot): add missing using for DirectSessionNotificationSender
Deploy Telegram Bot / build-and-push (push) Successful in 5m50s
Deploy Telegram Bot / scan-images (push) Successful in 2m29s
Deploy Telegram Bot / deploy (push) Successful in 36s
2026-05-24 07:50:05 +03:00
Toutsu 654db04d44 fix(discord): use dotnet/aspnet:10.0-noble runtime image
Deploy Telegram Bot / build-and-push (push) Failing after 42s
Deploy Telegram Bot / scan-images (push) Has been skipped
Deploy Telegram Bot / deploy (push) Has been skipped
2026-05-24 07:48:05 +03:00
Toutsu 3a94becf05 fix(bot): register DirectSessionNotificationSender in DI
Deploy Telegram Bot / build-and-push (push) Failing after 44s
Deploy Telegram Bot / scan-images (push) Has been skipped
Deploy Telegram Bot / deploy (push) Has been skipped
2026-05-24 07:48:04 +03:00
Toutsu 31d8f59f1e ci(deploy): reduce healthcheck timeout 180s -> 40s
Deploy Telegram Bot / build-and-push (push) Failing after 48s
Deploy Telegram Bot / scan-images (push) Has been skipped
Deploy Telegram Bot / deploy (push) Has been skipped
2026-05-24 07:48:04 +03:00
Toutsu 31e08ba073 ci(deploy): reduce healthcheck timeout 180s → 40s
Deploy Telegram Bot / build-and-push (push) Successful in 34s
Deploy Telegram Bot / scan-images (push) Successful in 1m51s
Deploy Telegram Bot / deploy (push) Failing after 1m22s
2026-05-24 07:38:34 +03:00
Toutsu 7c8e14c44f feat(ci): wait for bot/discord/web healthcheck after deploy
Deploy Telegram Bot / build-and-push (push) Successful in 41s
Deploy Telegram Bot / scan-images (push) Successful in 1m45s
Deploy Telegram Bot / deploy (push) Failing after 3m46s
Add loop that polls docker compose ps for healthy state.
Timeout: 180s, interval: 5s.
Workflow now fails if any service doesn't become healthy.
2026-05-24 07:33:57 +03:00
Toutsu b57332bd5c chore: remove AI working directories (docs/superpowers, docs/plans) from repo
Deploy Telegram Bot / build-and-push (push) Successful in 32s
Deploy Telegram Bot / scan-images (push) Successful in 1m45s
Deploy Telegram Bot / deploy (push) Successful in 15s
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>
2026-05-21 18:58:57 +03:00
Toutsu 73714c9525 docs(adr): add ADR-003 Discord Integration Architecture
Deploy Telegram Bot / build-and-push (push) Successful in 35s
Deploy Telegram Bot / scan-images (push) Successful in 1m57s
Deploy Telegram Bot / deploy (push) Successful in 14s
2026-05-21 18:40:30 +03:00
Toutsu 8319edda38 docs(adr-002): add links to issues #30-33 in related section
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 18:39:29 +03:00
Toutsu 5e1f0a00ad docs(adr-001): add Discord Gateway + NetCord decision, update Aspire services 2026-05-21 18:38:36 +03:00
Toutsu 987013974c docs(c4): update container view for Discord worker and healthcheck 2026-05-21 18:37:22 +03:00
Toutsu 7249ca079d docs(readme): update for v2.7.2 — Discord features, env vars, structure
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 18:34:46 +03:00
Toutsu 7fac5926fc docs: add design spec for MVP2 documentation sync
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 18:19:08 +03:00
Toutsu 9f7b772680 Merge pull request #90: test: добавить регрессионные тесты platform rendering и Discord MVP interactions (issue #33)
Deploy Telegram Bot / build-and-push (push) Successful in 4m43s
Deploy Telegram Bot / scan-images (push) Successful in 1m55s
Deploy Telegram Bot / deploy (push) Successful in 16s
2026-05-21 17:51:51 +03:00
Toutsu 1853a7a9c7 chore(release): bump version to 2.7.2
PR Checks / test-and-build (pull_request) Successful in 8m3s
Issue: #33
2026-05-21 15:36:17 +03:00
Toutsu befb2da6a0 test: add DiscordLandingPromisesSmokeTests
Mirror TelegramLandingPromisesSmokeTests for Discord MVP:
- Join/leave/waitlist promotion via capacity rules
- Reschedule voting flow
- Direct message notifications on reschedule
- Dashboard batch update

Issue: #33
2026-05-21 15:34:03 +03:00
Toutsu d29c6c0725 test: extend DiscordSessionBatchRendererTests with regression cases
- Confirmed status blue color
- Empty player description
- Embed URL from join link
- Inline field values for capacity/waitlist/status

Issue: #33
2026-05-21 15:21:02 +03:00
Toutsu 47b22c7401 test: extend TelegramSessionBatchRendererTests with regression cases
- Empty sessions
- HTML encoding in title
- Confirmed status buttons
- No join link handling

Issue: #33
2026-05-21 15:19:08 +03:00
Toutsu b4a39c027f test: extend SessionBatchViewBuilderTests with edge cases
- Empty sessions
- Confirmed status
- Null MaxPlayers

Issue: #33
2026-05-21 15:11:01 +03:00
Toutsu dd9eab2e4a Merge pull request #89: chore: добавить compose/deploy wiring для Discord bot (issue #32)
Deploy Telegram Bot / build-and-push (push) Successful in 4m50s
Deploy Telegram Bot / scan-images (push) Successful in 1m51s
Deploy Telegram Bot / deploy (push) Successful in 15s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:51:35 +03:00
Toutsu 492d47a863 fix(discord): add wget to Dockerfile for healthcheck
PR Checks / test-and-build (pull_request) Successful in 7m30s
Issue #32
2026-05-21 14:40:45 +03:00
Toutsu fe8d5fe026 test(discord): update version assertions to 2.7.1
PR Checks / test-and-build (pull_request) Successful in 7m7s
Issue #32
2026-05-21 14:26:02 +03:00
Toutsu a2fa9aaa6c chore(release): bump version to 2.7.1
Issue #32
2026-05-21 14:24:17 +03:00
Toutsu 5b65ac4a2f chore(discord): add healthcheck to compose.yaml discord service
Issue #32
2026-05-21 14:21:35 +03:00
Toutsu feb3e08b63 feat(discord): register health check hosted service in Program.cs
Issue #32
2026-05-21 14:20:40 +03:00
Toutsu f1d8f56fec feat(discord): add health check hosted service
Issue #32
2026-05-21 14:19:50 +03:00
Toutsu 08ffc6694e chore(discord): add DISCORD_BOT_TOKEN to .env.example
Issue #32
2026-05-21 14:19:02 +03:00
Toutsu 3199c48fcd Merge pull request #88: feat(platform): route scheduler notifications through platform messenger
Deploy Telegram Bot / build-and-push (push) Successful in 6m18s
Deploy Telegram Bot / scan-images (push) Successful in 1m44s
Deploy Telegram Bot / deploy (push) Successful in 16s
2026-05-21 12:40:22 +03:00
Toutsu 2a707e4825 feat(platform): route scheduler notifications through platform messenger
PR Checks / test-and-build (pull_request) Successful in 7m9s
2026-05-21 12:30:35 +03:00
Toutsu 5dbec1a0a4 docs: add issue 31 implementation plan 2026-05-20 14:53:41 +03:00
Toutsu 7426000937 docs: add issue 31 platform notification design 2026-05-20 14:38:27 +03:00
Toutsu 0c62631ab6 Merge pull request #87: feat(discord): implement reschedule voting via Discord interactions (issue #30)
Deploy Telegram Bot / build-and-push (push) Successful in 4m37s
Deploy Telegram Bot / scan-images (push) Successful in 1m25s
Deploy Telegram Bot / deploy (push) Successful in 14s
Database:
- Add source_platform and proposed_by_external_user_id to reschedule_proposals
- Make proposed_by nullable for Discord proposals

Shared:
- Extract platform-neutral RescheduleVoteRules, RescheduleVotingInput, RescheduleDtos
- Create RescheduleVotingFinalizer for cross-platform deadline handling

Telegram:
- Refactor RescheduleVotingDeadlineService to use RescheduleVotingFinalizer
- Tag Telegram proposals with source_platform = 'Telegram'

Discord:
- /reschedule slash command with time options and deadline
- DiscordRescheduleVoteHandler for button interactions
- DiscordRescheduleVotingRenderer for embeds and buttons
- DiscordRescheduleVotingDeadlineService for automatic finalization
- DiscordSessionInteractionModule routing for vote buttons

Version: 2.5.0 -> 2.6.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 13:12:26 +03:00
Toutsu db9a931ed6 fix(shared): filter due proposals by source_platform to prevent cross-platform race
PR Checks / test-and-build (pull_request) Successful in 6m11s
Both Telegram and Discord deadline services were querying ALL due
proposals without filtering by source_platform. If the Telegram
service reached a Discord proposal first, it finalized the DB state
but skipped message handling. The Discord service then saw status
!= 'Voting' and never updated the Discord vote message.

Fix: GetDueProposalIdsAsync now accepts a sourcePlatform parameter
and filters at the DB level. Each service only processes its own
platform's proposals.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:48:25 +03:00
Toutsu 35548a03cb test(discord): update version assertions to 2.6.0
PR Checks / test-and-build (pull_request) Successful in 6m27s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:05 +03:00
Toutsu dda393c372 chore: bump version to 2.6.0
Synchronized across Directory.Build.props, compose.yaml,
deploy.yml, and NavMenu.razor.

Bump version → 2.6.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:33:45 +03:00
Toutsu 1e9bf4ab25 feat(telegram): set source_platform = 'Telegram' on reschedule proposals
Ensures Telegram-initiated reschedule proposals are tagged with
source_platform so the platform-neutral finalizer can distinguish
them from Discord proposals.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:33:24 +03:00
Toutsu 690aa0272f feat(discord): add reschedule voting deadline service 2026-05-20 12:29:33 +03:00
Toutsu d871f2c142 feat(discord): implement SendGroupMessageAsync in DiscordPlatformMessenger 2026-05-20 12:26:31 +03:00
Toutsu 9712fe125b feat(discord): add DiscordRescheduleVotingRenderer and replace inline helper 2026-05-20 12:23:25 +03:00
Toutsu fdfc73ae9c feat(discord): add reschedule vote button handler 2026-05-20 12:21:13 +03:00
Toutsu e93e777fb3 feat(discord): add /reschedule slash command and handler 2026-05-20 12:15:03 +03:00
Toutsu a13edf20af feat(shared): add RescheduleVotingFinalizer and ISystemClock 2026-05-20 11:54:53 +03:00
Toutsu fcd7de035f refactor(shared): extract reschedule voting types to Shared 2026-05-20 11:44:57 +03:00
Toutsu fb0c29eefe feat(db): add platform columns to reschedule_proposals 2026-05-20 11:41:25 +03:00
Toutsu 9ff5cc4a67 Merge pull request #86: feat(discord): enable session join leave buttons
Deploy Telegram Bot / build-and-push (push) Successful in 4m54s
Deploy Telegram Bot / scan-images (push) Successful in 1m22s
Deploy Telegram Bot / deploy (push) Successful in 15s
2026-05-20 09:09:51 +03:00
Toutsu 3251846001 fix(shared): enable dapper aot for session handlers
PR Checks / test-and-build (pull_request) Successful in 6m30s
2026-05-20 09:01:34 +03:00
Toutsu 39132be4e8 feat(discord): enable session join leave buttons
PR Checks / test-and-build (pull_request) Successful in 6m6s
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
2026-05-19 14:13:48 +03:00
Toutsu 90da33154c Merge pull request #85: feat(discord): implement /newsession and /listsessions (issue #28)
Deploy Telegram Bot / build-and-push (push) Successful in 4m19s
Deploy Telegram Bot / scan-images (push) Successful in 1m17s
Deploy Telegram Bot / deploy (push) Successful in 13s
2026-05-19 12:53:35 +03:00
Toutsu d55003a2a9 feat(discord): improve UX and add source-level tests for /newsession
PR Checks / test-and-build (pull_request) Successful in 5m59s
- DiscordNewSessionCommand: on success, renders session details via
  DiscordSessionBatchRenderer.Render() with embeds and action rows.
- DiscordNewSessionCommand: uses Discord emoji shortcodes for error
  and success messages (, , 💥).
- DiscordNewSessionHandlerTests: added 7 source-level structural tests
  verifying Dapper usage, NpgsqlDataSource, permission checks,
  platform neutrality, transaction safety, CancellationToken usage,
  and embed rendering in the command.

Refs issue #28

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 12:36:17 +03:00
Toutsu daa59335cc fix(discord): resolve permission checking for /newsession command
- 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>
2026-05-19 12:30:25 +03:00
Toutsu 474e7f62f7 chore: bump version to 2.4.0
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>
2026-05-19 11:36:28 +03:00
Toutsu 8666b8984e feat: register Discord session handlers and permission checker in DI
Task 5: DI wiring for DiscordNewSessionHandler, DiscordListSessionsHandler,
DiscordPermissionChecker, and DiscordPlatformMessenger.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 11:33:33 +03:00
Toutsu d373ff49ba feat(discord): add DiscordPlatformMessenger IPlatformMessenger implementation
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 11:22:44 +03:00
Toutsu 95aad3a2f6 feat(discord): add /newsession slash command and handler
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 11:17:07 +03:00
Toutsu 76456cc28a feat(discord): add /listsessions slash command and handler
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 11:09:45 +03:00
Toutsu ac8f03ecc9 feat(discord): add DiscordPermissionChecker for session management rights
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 10:51:32 +03:00
Toutsu 21760ae6f7 Merge pull request #84: feat: implement DiscordSessionBatchRenderer for Embed and Buttons
Deploy Telegram Bot / build-and-push (push) Successful in 4m7s
Deploy Telegram Bot / scan-images (push) Successful in 1m13s
Deploy Telegram Bot / deploy (push) Successful in 12s
- Render SessionBatchViewModel into NetCord EmbedProperties + ActionRowProperties
- 7 tests covering open/full/waitlist/cancelled/reschedule states
- Version bump 2.2.0 → 2.3.0

Closes #27
2026-05-18 18:54:04 +03:00
Toutsu 5dddf99288 chore: bump version to 2.3.0
PR Checks / test-and-build (pull_request) Successful in 5m34s
Synchronized across Directory.Build.props, compose.yaml, deploy.yml, NavMenu.razor, and DiscordProjectStructureTests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 18:08:12 +03:00
Toutsu 1c75994722 feat: implement DiscordSessionBatchRenderer for Embed and Buttons
- Render SessionBatchViewModel into NetCord EmbedProperties + ActionRowProperties
- One embed per session with game title, Moscow date, players, capacity, waitlist, status
- Buttons map AvailableAction to ButtonProperties with platform-neutral custom IDs
- Cancelled sessions get embed but no action row
- Full sessions trigger waitlist button label
- 7 tests covering open/full/waitlist/cancelled/reschedule states

Closes #27

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 18:05:35 +03:00
Toutsu c0147fd310 test: add DiscordSessionBatchRenderer tests (RED)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:56:48 +03:00
Toutsu 745a65818d Merge pull request #83: feat: add Discord NetCord gateway worker
Deploy Telegram Bot / build-and-push (push) Successful in 4m9s
Deploy Telegram Bot / scan-images (push) Successful in 1m6s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-05-18 16:11:25 +03:00
Toutsu 05ca8061e9 feat: add Discord NetCord gateway worker
PR Checks / test-and-build (pull_request) Successful in 5m46s
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
2026-05-18 16:04:31 +03:00
Toutsu ab59d234f3 Merge pull request #82: refactor: make session join leave platform-neutral
Deploy Telegram Bot / build-and-push (push) Successful in 3m45s
Deploy Telegram Bot / scan-images (push) Successful in 1m0s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-05-18 13:38:22 +03:00
Toutsu e791fc2f4a refactor: make session join leave platform-neutral
PR Checks / test-and-build (pull_request) Successful in 5m3s
Convert join/leave interaction commands to PlatformUser, PlatformGroup, and PlatformMessageRef. Persist and look up participants by platform identity while keeping Telegram callbacks intact. Add V017 migration and TDD coverage. Bump version to 2.1.1.
2026-05-18 13:30:48 +03:00
Toutsu cb515b0e05 Merge pull request #81: feat: refresh dashboard design with fantasy RPG aesthetic
Deploy Telegram Bot / build-and-push (push) Successful in 3m57s
Deploy Telegram Bot / scan-images (push) Successful in 1m4s
Deploy Telegram Bot / deploy (push) Successful in 11s
🎨 Dashboard design refresh
- Complete fantasy RPG aesthetic overhaul
- Glass-morphism cards with gradient borders
- Cinzel + Jura typography
- Atmospheric backgrounds with noise texture

🧹 Chore: migrated k8s manifests to gmrelay-k8s repo

Bump version → 2.1.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:29:16 +03:00
Toutsu cea6ec801a chore: bump version to 2.1.0
PR Checks / test-and-build (pull_request) Successful in 5m11s
Synchronize version across all 4 files:
- Directory.Build.props
- compose.yaml (bot + web images)
- .gitea/workflows/deploy.yml
- NavMenu.razor

Bump version → 2.1.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:22:00 +03:00
Toutsu 8e57f8b07a chore: migrate k8s manifests to dedicated repo
PR Checks / test-and-build (pull_request) Successful in 5m11s
All Kubernetes manifests moved to git.codeanddice.ru/Toutsu/gmrelay-k8s.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:04:22 +03:00
Toutsu e837e191c2 feat: refresh dashboard design with fantasy RPG aesthetic
- Replace Inter font with Cinzel (headings) + Jura (body)
- Deepen dark background palette with atmospheric gradient orbs
- Add subtle noise texture overlay for depth
- Refine glass cards with animated gradient border glow on hover
- Sharpen accent colors: violet #8b5cf6 + cyan #22d3ee
- Improve button tactile feedback and shadow system
- Add k8s manifests for minikube local deployment

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 09:54:56 +03:00
Toutsu df01aa9f3e Merge pull request #80: refactor: add platform messenger contracts
Deploy Telegram Bot / build-and-push (push) Successful in 6m52s
Deploy Telegram Bot / scan-images (push) Successful in 3m37s
Deploy Telegram Bot / deploy (push) Successful in 19s
2026-05-15 18:46:30 +03:00
Toutsu 18e702cd04 fix: validate platform schedule update target
PR Checks / test-and-build (pull_request) Successful in 13m7s
2026-05-15 18:31:17 +03:00
Toutsu 5931099c14 style: remove reschedule prompt trailing whitespace
PR Checks / test-and-build (pull_request) Successful in 12m16s
2026-05-15 12:46:50 +03:00
Toutsu 8bcd16fbc9 refactor: add platform messenger contracts
PR Checks / test-and-build (pull_request) Successful in 12m35s
Introduce platform-neutral PlatformKind, PlatformUser, PlatformGroup, and IPlatformMessenger contracts in GmRelay.Shared.

Route Telegram session schedule updates, direct notifications, interaction replies, and calendar export through TelegramPlatformMessenger while preserving existing Telegram behavior.

Bump version -> 2.0.1
2026-05-15 12:30:37 +03:00
Toutsu 7cecb722d8 Merge pull request #79: chore: add platform identity and platform_messages for multi-platform support (#23)
Deploy Telegram Bot / build-and-push (push) Successful in 7m11s
Deploy Telegram Bot / scan-images (push) Successful in 2m41s
Deploy Telegram Bot / deploy (push) Successful in 17s
PR Checks / test-and-build (pull_request) Successful in 11m17s
2026-05-15 11:02:23 +03:00
Toutsu 11b145a967 chore: add platform identity and platform_messages for multi-platform support (#23)
PR Checks / test-and-build (pull_request) Successful in 9m36s
TDD cycle for issue #23:
- RED: 9 migration smoke tests (file presence + schema expectations)
- GREEN: V016 migration adding platform identity columns
- GREEN: CreateSessionHandler, JoinSessionHandler, Web SessionService updated
  with dual-write to legacy and new identity columns + COALESCE fallbacks
- GREEN: get_group_attendance_stats recreated for external_username
- Bump version to 2.0.0

Changes:
- V016__add_platform_identity.sql:
  - players: platform, external_user_id, external_username
  - game_groups: platform, external_group_id, external_channel_id
  - platform_messages table with cross-platform message tracking
  - Backfill all existing Telegram data into new columns
  - Recreate get_group_attendance_stats with COALESCE fallback
- V012__add_attendance_stats.sql: use COALESCE(external_username, telegram_username)
- CreateSessionHandler: dual-write + COALESCE fallbacks in SELECTs
- JoinSessionHandler: dual-write to new identity columns
- Web SessionService: dual-write to new identity columns
- PlatformIdentityMigrationTests (9 smoke tests covering all handlers)
- Version synced: Directory.Build.props, compose.yaml, deploy.yml, NavMenu.razor → 2.0.0

Legacy telegram_* columns preserved for backward compatibility.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 10:48:10 +03:00
Toutsu 105b3c59d7 fix: address review feedback for health check endpoints
PR Checks / test-and-build (pull_request) Successful in 8m34s
- Install wget in Web Dockerfile for compose healthcheck
- Ensure HttpListener response is always closed in BotHealthCheckHostedService
- Use ephemeral port in Bot health check test to avoid port conflicts
- Rename NpgsqlHealthCheck test to reflect actual behavior

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 11:16:58 +03:00
Toutsu 3bea327043 feat: add health check endpoints for Bot and Web
PR Checks / test-and-build (pull_request) Successful in 8m53s
- Web: add /health endpoint with PostgreSQL readiness check (returns 200+JSON or 503)
- Web: add /alive endpoint for liveness probe
- Bot: add BotHealthCheckHostedService serving /health on port 8081 via HttpListener
- Bot: expose port 8081 in Dockerfile and install wget for healthcheck
- compose.yaml: add healthcheck sections for bot and web services
- tests: add TDD tests for both health endpoints

Bump version -> 1.16.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 10:54:22 +03:00
Toutsu c6aea78ff3 Delete directory '.hermes/plans'
Deploy Telegram Bot / build-and-push (push) Successful in 1m58s
Deploy Telegram Bot / scan-images (push) Successful in 2m7s
Deploy Telegram Bot / deploy (push) Successful in 14s
2026-05-13 08:36:49 +03:00
Toutsu 01c49f2df0 Merge pull request #62: docs: add MIT LICENSE file
Deploy Telegram Bot / build-and-push (push) Successful in 3m57s
Deploy Telegram Bot / scan-images (push) Successful in 2m4s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-05-12 16:50:20 +03:00
Toutsu 9deccd3a9d docs: add MIT LICENSE file
PR Checks / test-and-build (pull_request) Successful in 7m7s
Add LICENSE file with MIT License text to repository root.
README.md already references it; the file was missing.

Includes TDD-verified tests ensuring LICENSE exists and contains
MIT License text, and README references it correctly.

Bump version → 1.15.1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 16:25:17 +03:00
Toutsu 81d4ec2c97 fix(web): ensure dataprotection-keys dir is owned by app user before switching USER
Deploy Telegram Bot / build-and-push (push) Successful in 3m58s
Deploy Telegram Bot / scan-images (push) Successful in 1m58s
Deploy Telegram Bot / deploy (push) Successful in 13s
The volume mount /app/dataprotection-keys was created under root:root
permissions on the host. When the container restarted with the 1.15.0
image, the non-root app user (uid=1654) could no longer read/write
DataProtection keys, causing every request to fail with
UnauthorizedAccessException and fall back to the generic /Error page.

Add RUN chown during the final Docker stage so the directory ownership
matches the runtime user before USER $APP_UID takes effect.
2026-05-12 16:05:48 +03:00
Toutsu c0a5482e1a Merge pull request #61: infra: add PostgreSQL daily backup via pg_dump with rotation
Deploy Telegram Bot / build-and-push (push) Successful in 3m58s
Deploy Telegram Bot / scan-images (push) Successful in 1m44s
Deploy Telegram Bot / deploy (push) Successful in 14s
- Add db-backup service to compose.yaml (postgres:17-alpine + cron)
- Add pgbackups volume for backup storage
- Add scripts/restore.sh for manual restore from latest backup
- Update .env.example with BACKUP_RETENTION_DAYS and BACKUP_VOLUME_NAME
- Document backup/restore flow in README

Bump version -> 1.15.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 14:16:11 +03:00
Toutsu 5a18cacb2e fix: address review feedback for backup infrastructure
PR Checks / test-and-build (pull_request) Successful in 6m52s
- compose.yaml: rewrite db-backup to use heredoc script instead of inline
cron command, fixing date escaping and adding temp-file pipeline for
reliable error detection
- compose.yaml: fix pipefail issue by writing pg_dump to tmp file before
compression and rotation
- restore.sh: pass PGPASSWORD explicitly via docker compose exec -e
- restore.sh: use ". .env" with set -a/+a instead of fragile xargs export

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 14:04:53 +03:00
Toutsu 121272fdfe infra: add PostgreSQL daily backup via pg_dump with rotation
PR Checks / test-and-build (pull_request) Successful in 6m24s
- Add db-backup service to compose.yaml (postgres:17-alpine + cron)
- Add pgbackups volume for backup storage
- Add scripts/restore.sh for manual restore from latest backup
- Update .env.example with BACKUP_RETENTION_DAYS and BACKUP_VOLUME_NAME
- Document backup/restore flow in README

Bump version -> 1.15.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 13:36:47 +03:00
Toutsu ccf11457ca Merge pull request #56: ci: add Trivy security scanning (SAST/SCA) to pipeline
Deploy Telegram Bot / build-and-push (push) Successful in 24s
Deploy Telegram Bot / scan-images (push) Successful in 1m23s
Deploy Telegram Bot / deploy (push) Successful in 11s
- Trivy fs scan (vuln, misconfig, secret) with lock file verification
- Trivy image scan before deploy
- SecurityCodeScan deep SAST via Roslyn analyzers
- NuGet vulnerability audit via dotnet list package
- C# code style linting via dotnet format
2026-05-12 13:07:20 +03:00
Toutsu e492d4fc2d Merge branch 'main' of ssh://git.codeanddice.ru:222/Toutsu/GmRelayBot 2026-05-12 13:07:20 +03:00
Toutsu 11f6b1bcc9 Merge remote-tracking branch 'origin/main' into feature/trivy-security-scan
PR Checks / test-and-build (pull_request) Successful in 5m50s
2026-05-12 12:59:49 +03:00
Toutsu 06d40fdbc8 ci: add deep SAST via SecurityCodeScan Roslyn analyzer
PR Checks / security-scan (pull_request) Failing after 1m17s
PR Checks / test-and-build (pull_request) Successful in 3m27s
- SecurityCodeScan.VS2019 5.6.7 injected into Directory.Build.props
  scans all C# source during every dotnet build
- HIGH/CRITICAL findings fail the build because TreatWarningsAsErrors=true
- No extra CI step needed: analyzer runs inside every build job automatically

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 12:45:36 +03:00
Toutsu 043ed9ce45 ci: add Trivy security scanning (SAST/SCA) to pipeline
PR Checks / security-scan (pull_request) Failing after 1m15s
PR Checks / test-and-build (pull_request) Successful in 3m24s
- PR checks: filesystem scan with Trivy (vuln, secret, misconfig)
- Deploy pipeline: image scan for bot and web containers before deploy
- Scans entire repository, not filtered file subsets
- Bump version -> 1.14.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 12:42:32 +03:00
Toutsu 320aba2877 Merge pull request #55: feat(#21): support selected Telegram topics for schedules
Deploy Telegram Bot / build-and-push (push) Successful in 4m1s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-05-12 12:40:20 +03:00
Toutsu e3fdac15b5 ci: satisfy trivy dockerfile checks
PR Checks / test-and-build (pull_request) Successful in 5m12s
Run runtime images as the built-in non-root .NET app user and install Web runtime OS dependencies with --no-install-recommends.
2026-05-12 12:31:20 +03:00
Toutsu 105a051c2f ci: install latest trivy and verify scan inputs
PR Checks / test-and-build (pull_request) Failing after 6m30s
Enable NuGet lock files so Trivy has dependency targets, fail PR checks when no lock files or language-specific files are detected, and let the installer fetch the latest Trivy release.
2026-05-12 12:20:42 +03:00
Toutsu de9f56c97d feat(#21): support selected telegram topics for schedules
PR Checks / test-and-build (pull_request) Failing after 3m18s
Route new schedules to an existing forum topic when /newsession is sent inside one, create bot-owned topics only from the forum root, and keep group notifications/dashboard updates threaded to the stored topic.

Persist topic ownership so deletion only removes empty bot-created topics, add topic routing tests and smoke coverage, and bump release metadata to 1.14.0.
2026-05-12 12:07:51 +03:00
Hermes Agent 007806a5d8 feat(ci): add C# linter and security scanner to PR checks
Deploy Telegram Bot / build-and-push (push) Successful in 24s
Deploy Telegram Bot / deploy (push) Successful in 10s
- dotnet format --verify-no-changes (C# code style linting)
- dotnet list package --vulnerable --include-transitive (NuGet vulnerability check)
- Trivy filesystem scan (CVE, secrets, dependency scanning)
2026-05-11 20:11:15 +00:00
Toutsu c9627e51a2 chore: ignore .claude and .serena directories 2026-05-11 14:29:04 +03:00
Toutsu 2a3285996e Merge pull request #53: feat(#20): довести RSVP и напоминания до полного набора событий
Deploy Telegram Bot / build-and-push (push) Successful in 3m54s
Deploy Telegram Bot / deploy (push) Successful in 13s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:54:56 +03:00
Toutsu 025c7c2f9a fix(#20): reset confirmation_sent_at on reschedule and add guard
PR Checks / test-and-build (pull_request) Successful in 3m17s
- RescheduleVotingDeadlineService: clear confirmation_sent_at +
  confirmation_message_id when moving session back to Planned.
- HandleRescheduleTimeInputHandler.RescheduleImmediately: same reset.
- SendConfirmationHandler: add confirmation_sent_at IS NULL guard
  to prevent duplicate confirmation messages if DB update fails.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:49:30 +03:00
Toutsu e6e6d17b72 feat(#20): довести RSVP и напоминания до полного набора событий
PR Checks / test-and-build (pull_request) Successful in 3m12s
- Добавлена абстракция ISystemClock + SystemClock / FakeSystemClock
  для тестируемого scheduling.
- Добавлена миграция V014: confirmation_sent_at в sessions.
- Обновлен SendConfirmationHandler: записывает confirmation_sent_at.
- Обновлен SessionSchedulerService:
  - выделен ISessionTriggerStore / DbSessionTriggerStore
  - SQL-запросы используют параметр @Now вместо now()
  - добавлен публичный TickAsync для тестов
  - защита от дублей через confirmation_sent_at IS NULL
- Обновлен RescheduleVotingDeadlineService: использует ISystemClock.
- Добавлены интерфейсы ISendConfirmationHandler, ISendOneHourReminderHandler,
  ISendJoinLinkHandler для unit-тестируемости.
- Добавлены 8 unit-тестов SessionSchedulerService:
  - все 3 триггера (T-24h, T-1h, T-5min)
  - идемпотентность при повторном запуске
  - ошибки handler не падают и не блокируют другие сессии
  - ошибки store логируются без падения worker-а

Bump version -> 1.13.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:38:34 +03:00
Toutsu 563e118f23 Merge pull request #52: feat(#15): add session audit log history tests and bump version to 1.12.0
Deploy Telegram Bot / build-and-push (push) Successful in 3m58s
Deploy Telegram Bot / deploy (push) Successful in 13s
2026-05-10 19:04:46 +03:00
Toutsu e2303490e9 feat(#15): add session audit log history tests and bump version to 1.12.0
PR Checks / test-and-build (pull_request) Successful in 4m4s
Adds missing tests for GetSessionHistoryForGmAsync authorization.
Syncs version across all 4 files for the 1.12.0 minor release.

Bump version -> 1.12.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 18:57:07 +03:00
Toutsu 9c1c6c2483 Merge pull request #51: feat(#19): добавить ссылку на игру в карточку батча
Deploy Telegram Bot / build-and-push (push) Successful in 4m12s
Deploy Telegram Bot / deploy (push) Successful in 13s
2026-05-10 18:18:50 +03:00
Toutsu c0c8f852d2 feat(#19): добавить ссылку на игру в карточку батча
PR Checks / test-and-build (pull_request) Successful in 3m49s
- SessionBatchDto: добавлено поле JoinLink
- SessionViewItem: добавлено поле JoinLink
- SessionBatchViewBuilder: прокидывание JoinLink из DTO в ViewModel
- CreateSessionHandler, SessionService: обновлены все вызовы конструктора
- TelegramSessionBatchRenderer (Bot + Web): рендеринг ссылки в карточке
- Добавлены тесты на наличие ссылки в рендере
- Все 7 SQL-запросов, загружающих SessionBatchDto, обновлены с join_link AS JoinLink
- Бамп версии до 1.11.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 18:13:55 +03:00
Toutsu ac6e2455a1 Merge pull request #50: fix(ui): prevent NavMenu logo from overlapping hamburger on mobile
Deploy Telegram Bot / build-and-push (push) Successful in 3m47s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-05-08 13:57:04 +03:00
Toutsu 9374ff16ed fix(ui): prevent NavMenu logo from overlapping hamburger on mobile
PR Checks / test-and-build (pull_request) Successful in 3m37s
On viewports ≤768px the burger button is position:fixed at the
viewport edge, while the header retained its default 1rem left
padding. The logo image therefore sat completely underneath the
button, causing a visible overlap on hover.

Increase .nav-header padding-left to 3.75rem on mobile so the
.nav-brand clears the 2.5rem fixed toggle with a 0.5rem gap.

Bump version → 1.10.6

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:41:48 +03:00
Toutsu 17b92b25f4 Merge pull request #49: feat(ui): replace emoji logos with new app icon across dashboard
Deploy Telegram Bot / build-and-push (push) Successful in 3m43s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-05-08 13:24:02 +03:00
Toutsu d2edbf16cc fix(ci): bump version to 1.10.5
PR Checks / test-and-build (pull_request) Successful in 3m49s
Synchronize version across:
- Directory.Build.props
- compose.yaml (bot and web images)
- deploy.yml
- NavMenu version display

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:16:16 +03:00
Toutsu b16627c2b6 feat(ui): replace emoji logos with new app icon across dashboard
- NavMenu: swap 🐢 emoji for <img src="logo.png">
- Login page: swap 🎲 emoji for <img src="logo.png">
- Mini App page: swap 🎲 emoji for <img src="logo.png">
- Replace favicon.png with the new logo
- Add logo.png to wwwroot
- Update CSS for .nav-brand-icon, .login-logo, .mini-app-logo to use object-fit: contain sizing

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:15:53 +03:00
Toutsu 4f7afb3bc9 fix(ci): sync NavMenu version to 1.10.4
Deploy Telegram Bot / build-and-push (push) Successful in 3m42s
Deploy Telegram Bot / deploy (push) Successful in 9s
2026-05-07 16:24:46 +03:00
Toutsu 5baf63e9ad fix(ci): sync compose.yaml images to 1.10.4
Deploy Telegram Bot / build-and-push (push) Successful in 24s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-05-07 16:24:15 +03:00
Hermes Agent a0d9d1bc44 fix(#47): use align-items: baseline + vertical-align + nudge for emoji icon
Deploy Telegram Bot / build-and-push (push) Successful in 3m34s
Deploy Telegram Bot / deploy (push) Successful in 9s
2026-05-07 13:18:57 +00:00
Hermes Agent f46f2bb5d3 fix(ci): bump deploy.yml VERSION to 1.10.3
Deploy Telegram Bot / build-and-push (push) Successful in 22s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-05-07 13:11:40 +00:00
Hermes Agent 46527fe761 fix(#47): align NavMenu emoji icon — line-height: 1, increase gap
PR Checks / test-and-build (pull_request) Successful in 3m17s
Deploy Telegram Bot / build-and-push (push) Successful in 3m47s
Deploy Telegram Bot / deploy (push) Failing after 7s
2026-05-07 12:59:50 +00:00
Hermes Agent d0a25895ab fix(#15): make test time stable — use same DateTime instance for unchanged fields
PR Checks / test-and-build (pull_request) Successful in 3m11s
Deploy Telegram Bot / build-and-push (push) Successful in 3m52s
Deploy Telegram Bot / deploy (push) Failing after 7s
2026-05-07 12:46:12 +00:00
Hermes Agent 05faa9e32d fix(#15): correct test — only title changes when other fields stay same
PR Checks / test-and-build (pull_request) Failing after 3m9s
2026-05-07 12:41:30 +00:00
Hermes Agent 0dbd4064ac fix(#15): bump NavMenu version and fix audit log test expectations for MaxPlayers
PR Checks / test-and-build (pull_request) Failing after 3m11s
2026-05-07 12:37:36 +00:00
Hermes Agent 0f03da0a60 docs(#15): bump version to 1.10.2 and add session history feature to README
PR Checks / test-and-build (pull_request) Failing after 3m19s
2026-05-07 12:30:11 +00:00
Hermes Agent 6d90ba8274 feat(#15): add SessionHistory.razor, navigation links, and bump version to 1.10.2 2026-05-07 12:20:44 +00:00
Hermes Agent 35894bf89e feat(#15): session audit log domain, store, and instrumentation 2026-05-07 12:16:54 +00:00
root 6394b1fe8c fix: mobile menu overlay z-index and add stats link on group page 2026-05-07 12:08:37 +00:00
Toutsu d170c83b9e docs(#14): добавить статистику посещаемости и обновить версию в README
Deploy Telegram Bot / build-and-push (push) Successful in 22s
Deploy Telegram Bot / deploy (push) Successful in 10s
2026-05-07 14:48:35 +03:00
Toutsu 4a2d1d2d38 Merge pull request 'feat(#14): attendance statistics page' (#45) from issue-14-attendance-stats into main
Deploy Telegram Bot / build-and-push (push) Successful in 3m57s
Deploy Telegram Bot / deploy (push) Successful in 12s
feat(#14): attendance statistics page
2026-05-07 14:32:40 +03:00
root 706f20e403 fix: add GetGroupAttendanceStatsAsync stub to FakeSessionStore in tests
PR Checks / test-and-build (pull_request) Successful in 3m14s
Resolves CS0535 build failure in test project.
2026-05-07 11:26:22 +00:00
root 4d3362d93f fix: GroupStats.razor syntax and missing using for Claims
PR Checks / test-and-build (pull_request) Failing after 3m14s
- Add @using System.Security.Claims
- Fix quotation marks in @onclick lambdas (Razor parser error CS1026)
2026-05-07 11:21:42 +00:00
root b03929174a fix: move PlayerAttendanceStats out of interface scope
PR Checks / test-and-build (pull_request) Failing after 2m53s
The record was nested inside ISessionStore, making it ISessionStore.PlayerAttendanceStats.
C# does not infer nested types in return signatures; callers and implementors failed
with CS0246 / CS0738. Moving it to namespace scope resolves the build.
2026-05-07 11:16:13 +00:00
root 7e2747ec73 feat: implement GetGroupAttendanceStatsAsync (#14)
PR Checks / test-and-build (pull_request) Failing after 2m57s
2026-05-07 11:05:38 +00:00
Toutsu ae6be912e3 feat(#14): add GroupStats.razor attendance page
PR Checks / test-and-build (pull_request) Failing after 3m14s
2026-05-07 13:26:03 +03:00
Toutsu 116bed16a8 feat(#14): add PlayerAttendanceStats record + interface method 2026-05-07 13:26:01 +03:00
Toutsu 063de7ee3e feat(#14): add get_group_attendance_stats SQL function 2026-05-07 13:12:39 +03:00
Toutsu 5c4ec562d0 Merge pull request 'feat(#13): календарная подписка по URL' (#44) from issue-13-calendar-sub into main
Deploy Telegram Bot / build-and-push (push) Failing after 16m3s
Deploy Telegram Bot / deploy (push) Has been skipped
Reviewed-on: #44
2026-05-07 10:59:50 +03:00
Toutsu dbd481566c fix(#13): bump version label in NavMenu to v1.10.1
PR Checks / test-and-build (pull_request) Successful in 3m57s
2026-05-07 10:32:23 +03:00
Toutsu 3f4571d3a7 chore(#13): bump version to 1.10.1
PR Checks / test-and-build (pull_request) Failing after 4m26s
2026-05-07 10:25:25 +03:00
Toutsu 8c1e7991cd feat(#13): add calendar subscription link to Telegram export 2026-05-07 10:22:35 +03:00
Toutsu c1fdba510b feat(#13): add Web:BaseUrl config for calendar subscription links 2026-05-07 10:21:07 +03:00
Toutsu 435399dcf2 fix(#13): revert ExportCalendarHandler subscription logic (cross-project ref) 2026-05-07 10:18:25 +03:00
Toutsu ddaa0f4279 feat(#13): register CalendarSubscriptionService and add public /calendar/{token}.ics endpoint 2026-05-07 10:16:02 +03:00
Toutsu b205967f1a feat(#13): add CalendarSubscriptionService with token generation and ICS rendering 2026-05-07 10:15:06 +03:00
Toutsu 7457315d6f feat(#13): add SubscriptionNotFoundException 2026-05-07 10:13:45 +03:00
Toutsu 59f9904d66 feat(#13): add CalendarSubscriptionFilter enum 2026-05-07 10:12:34 +03:00
root 3b91a009ea feat(#13): add calendar subscriptions migration 2026-05-07 06:59:56 +00:00
root a6ae5aac31 refactor(#22): merge platform-neutral batch rendering PR
Deploy Telegram Bot / build-and-push (push) Successful in 5m2s
Deploy Telegram Bot / deploy (push) Successful in 14s
2026-05-06 10:35:40 +00:00
root dc26b4d7e4 test: trigger pr-checks workflow
PR Checks / test-and-build (pull_request) Successful in 4m24s
2026-05-06 10:25:02 +00:00
root bc6136d91e chore(web): bump NavMenu version label to v1.10.0
Deploy Telegram Bot / build-and-push (push) Successful in 4m13s
Deploy Telegram Bot / deploy (push) Successful in 13s
2026-05-06 10:24:32 +00:00
root 2e95841ca8 fix(tests): avoid xUnit2013 analyzer error on collection count
Deploy Telegram Bot / build-and-push (push) Successful in 22s
Deploy Telegram Bot / deploy (push) Successful in 14s
2026-05-06 10:14:13 +00:00
root a7c8127f90 fix(tests): add missing using and fix xUnit2013 analyzer error
Deploy Telegram Bot / build-and-push (push) Successful in 21s
Deploy Telegram Bot / deploy (push) Successful in 13s
2026-05-06 10:06:27 +00:00
root cad4e5c30e fix(ci): remove --no-build from dotnet test step
Deploy Telegram Bot / build-and-push (push) Successful in 21s
Deploy Telegram Bot / deploy (push) Successful in 13s
2026-05-06 09:52:46 +00:00
root 77647e4bb8 fix(ci): use ubuntu runner + setup-dotnet instead of container image
Deploy Telegram Bot / build-and-push (push) Successful in 19s
Deploy Telegram Bot / deploy (push) Successful in 13s
2026-05-06 09:46:52 +00:00
root 17c631aef2 ci: add PR checks workflow — test + build, no publish
Deploy Telegram Bot / build-and-push (push) Successful in 21s
Deploy Telegram Bot / deploy (push) Successful in 10s
2026-05-06 09:40:11 +00:00
root 89b5196676 fix(#22): resolve Telegram namespace collision and add missing MoscowTime using
Deploy Telegram Bot / build-and-push (push) Successful in 7m24s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-05-06 09:23:52 +00:00
root ab1d2f1683 refactor(#22): platform-neutral batch rendering
Deploy Telegram Bot / build-and-push (push) Failing after 34s
Deploy Telegram Bot / deploy (push) Has been skipped
2026-05-06 09:17:05 +00:00
root 1bcd88db32 ci: bump deploy workflow version to 1.10.0 2026-05-06 09:14:29 +00:00
root 63e613c061 trigger: ci 2026-05-06 09:12:57 +00:00
Toutsu dbf59c544a docs(adr): добавить ADR 002 — platform-neutral batch rendering 2026-05-06 12:07:10 +03:00
root 14b9bf15f2 refactor(#22): разделить SessionBatchRenderer на neutral view и Telegram renderer
- SessionBatchViewBuilder в Shared собирает нейтральную view model
- TelegramSessionBatchRenderer в Bot/Web рендерит HTML + InlineKeyboardMarkup
- DiscordSessionBatchRenderer заглушка подготовлена
- BatchMessageEditor перенесён из Shared в Bot/Web
- Удалён SessionBatchRenderer, убран Telegram.Bot из Shared.csproj
- Обновлены все вызовы (7 handler-ов + Web SessionService + smoke tests)
- Новые тесты на builder и Telegram renderer
2026-05-06 08:28:25 +00:00
Toutsu 5dee2d87f5 test: cover Telegram landing promise smoke
Deploy Telegram Bot / build-and-push (push) Successful in 5m32s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-05-05 13:06:09 +03:00
root b71488097e chore: bump version to 1.9.8
Deploy Telegram Bot / build-and-push (push) Successful in 21s
Deploy Telegram Bot / deploy (push) Successful in 7s
2026-05-04 17:26:53 +00:00
root 6e92419cff feat: player list, kick, and waitlist promotion (#41)
Deploy Telegram Bot / build-and-push (push) Successful in 4m53s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-05-04 17:19:58 +00:00
root fdb3445bec docs: bump README to v1.9.7, document player list kick 2026-05-04 17:15:06 +00:00
root c1f5d96e25 feat: show participant list, kick player, auto-promote waitlist 2026-05-04 17:11:23 +00:00
Toutsu c874f7b797 fix: combine session image and text into single Telegram message
Deploy Telegram Bot / build-and-push (push) Successful in 4m2s
Deploy Telegram Bot / deploy (push) Successful in 10s
When creating a session with an image, send it as a single SendPhoto
with the schedule text as caption (+ reply markup), instead of two
separate messages. Falls back to two messages if caption exceeds
Telegram's 1024-char limit.

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

Version bump to 1.9.7.
2026-05-04 10:33:06 +03:00
Toutsu aefed5abd4 feat: improve telegram session posts
Deploy Telegram Bot / build-and-push (push) Successful in 4m28s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-05-04 09:52:07 +03:00
Toutsu 25c22b2ff5 fix: stabilize session table layout
Deploy Telegram Bot / build-and-push (push) Successful in 4m6s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-05-02 15:40:24 +03:00
Toutsu cb40c2438d docs: clarify mini app dashboard for GMs
Deploy Telegram Bot / build-and-push (push) Successful in 4m18s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-04-28 21:01:30 +03:00
Toutsu 2a76ec0fb8 fix: stabilize mini app login and safe area
Deploy Telegram Bot / build-and-push (push) Successful in 3m53s
Deploy Telegram Bot / deploy (push) Successful in 17s
2026-04-28 20:25:18 +03:00
Toutsu 57c8714889 fix: refresh login fallback in mini app
Deploy Telegram Bot / build-and-push (push) Successful in 4m11s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-04-28 17:20:29 +03:00
Toutsu 8220f2060f fix: refresh mini app login state
Deploy Telegram Bot / build-and-push (push) Successful in 4m23s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-04-28 17:03:53 +03:00
Toutsu 41f2ea6e90 feat: add telegram mini app dashboard
Deploy Telegram Bot / build-and-push (push) Successful in 23s
Deploy Telegram Bot / deploy (push) Successful in 10s
2026-04-28 14:56:55 +03:00
Toutsu 5082dd4fcf fix: stack sidebar template nav item
Deploy Telegram Bot / build-and-push (push) Successful in 3m45s
Deploy Telegram Bot / deploy (push) Successful in 9s
2026-04-28 10:36:52 +03:00
Toutsu cfbda4ca05 fix: move campaign templates to dedicated tab
Deploy Telegram Bot / build-and-push (push) Successful in 3m28s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-04-28 10:22:12 +03:00
Toutsu 0218890a7a feat: add campaign templates and recurring schedules
Deploy Telegram Bot / build-and-push (push) Successful in 3m49s
Deploy Telegram Bot / deploy (push) Successful in 10s
2026-04-28 10:01:18 +03:00
Toutsu a1ec688ec8 feat: add multi-option reschedule voting
Deploy Telegram Bot / build-and-push (push) Successful in 3m44s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-04-27 14:58:32 +03:00
Toutsu 2529df4157 feat: support co-gm group delegation
Deploy Telegram Bot / build-and-push (push) Successful in 3m51s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-04-27 14:27:16 +03:00
Toutsu a8f2b10956 feat: send personal player notifications
Deploy Telegram Bot / build-and-push (push) Successful in 3m36s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-04-27 10:11:11 +03:00
Toutsu 3228e77c7f fix: improve select dropdown contrast
Deploy Telegram Bot / build-and-push (push) Successful in 3m29s
Deploy Telegram Bot / deploy (push) Successful in 10s
2026-04-27 09:46:40 +03:00
Toutsu 621ef553e7 feat: add web batch bulk operations
Deploy Telegram Bot / build-and-push (push) Successful in 3m21s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-04-27 09:31:51 +03:00
Toutsu 5f3516e703 fix: push registry images with docker cli
Deploy Telegram Bot / build-and-push (push) Successful in 1m7s
Deploy Telegram Bot / deploy (push) Successful in 13s
2026-04-24 18:20:15 +03:00
Toutsu 2eb7d86e48 fix: disable build provenance for gitea registry
Deploy Telegram Bot / build-and-push (push) Failing after 30s
Deploy Telegram Bot / deploy (push) Has been skipped
2026-04-24 18:17:20 +03:00
Toutsu 3e291b0ed5 chore: restore registry PAT login
Deploy Telegram Bot / build-and-push (push) Failing after 35s
Deploy Telegram Bot / deploy (push) Has been skipped
2026-04-24 18:11:11 +03:00
Toutsu a5ba4111cf fix: use gitea job token for registry push
Deploy Telegram Bot / build-and-push (push) Failing after 18s
Deploy Telegram Bot / deploy (push) Has been skipped
2026-04-24 18:09:12 +03:00
Toutsu f45985041b feat: allow players to leave sessions
Deploy Telegram Bot / build-and-push (push) Failing after 39s
Deploy Telegram Bot / deploy (push) Has been skipped
2026-04-24 17:57:13 +03:00
Toutsu 9c91057798 feat: add session capacity waitlist
Deploy Telegram Bot / build-and-push (push) Failing after 4m42s
Deploy Telegram Bot / deploy (push) Has been skipped
2026-04-24 13:28:01 +03:00
Toutsu 675ac1226e chore: make compose config portable
Deploy Telegram Bot / build-and-push (push) Successful in 40s
Deploy Telegram Bot / deploy (push) Successful in 17s
2026-04-24 10:44:33 +03:00
Toutsu b80002aa36 refactor: unify session status model
Deploy Telegram Bot / build-and-push (push) Successful in 4m47s
Deploy Telegram Bot / deploy (push) Successful in 19s
Fixes #5
2026-04-24 10:26:45 +03:00
Toutsu bb8cbb7a40 test: cover core bot and web scenarios
Deploy Telegram Bot / build-and-push (push) Successful in 4m18s
Deploy Telegram Bot / deploy (push) Successful in 20s
2026-04-23 21:08:41 +03:00
Toutsu 93e7c1ac66 chore: поднятие версии до 1.1.2 во всех файлах конфигурации
Deploy Telegram Bot / build-and-push (push) Successful in 42s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-04-23 20:49:01 +03:00
Toutsu 4d6651827b fix: skip stale pending updates on startup
Deploy Telegram Bot / build-and-push (push) Successful in 4m24s
Deploy Telegram Bot / deploy (push) Successful in 18s
2026-04-23 20:42:16 +03:00
Toutsu 9e7a202f42 fix: redact bot secrets in startup logs
Deploy Telegram Bot / build-and-push (push) Successful in 4m24s
Deploy Telegram Bot / deploy (push) Successful in 21s
2026-04-23 20:28:52 +03:00
Toutsu 1c4cfb71c0 fix: close web access to foreign groups and sessions
Deploy Telegram Bot / build-and-push (push) Successful in 7m25s
Deploy Telegram Bot / deploy (push) Successful in 18s
2026-04-23 20:09:22 +03:00
Toutsu ecc2236937 chore: поднятие версии до 1.1.1 во всех файлах конфигурации
Deploy Telegram Bot / build-and-push (push) Successful in 25s
Deploy Telegram Bot / deploy (push) Successful in 8s
2026-04-21 15:47:43 +03:00
Toutsu 3002db6534 fix: загрузка Telegram Login Widget через JS interop для корректной работы с Blazor SPA-навигацией
Deploy Telegram Bot / build-and-push (push) Successful in 3m17s
Deploy Telegram Bot / deploy (push) Successful in 9s
2026-04-21 15:31:12 +03:00
357 changed files with 48755 additions and 2212 deletions
+28
View File
@@ -6,5 +6,33 @@ TELEGRAM_BOT_TOKEN=YOUR_BOT_TOKEN_HERE
# Найти его можно в информации о боте у @BotFather.
TELEGRAM_BOT_USERNAME=YOUR_BOT_USERNAME_HERE
# HTTPS URL Mini App dashboard, например: https://your-domain.example/miniapp
# Используется ботом для кнопки меню Telegram и кнопки /start.
TELEGRAM_MINI_APP_URL=
# Токен Discord application bot
# Можно получить в Discord Developer Portal (https://discord.com/developers/applications)
DISCORD_BOT_TOKEN=YOUR_DISCORD_BOT_TOKEN_HERE
# Discord OAuth (для Web Dashboard)
# Client ID и Secret из OAuth2 раздела Discord Developer Portal
# Redirect URI должен указывать на /auth/discord/callback вашего домена
DISCORD_CLIENT_ID=YOUR_DISCORD_CLIENT_ID_HERE
DISCORD_CLIENT_SECRET=YOUR_DISCORD_CLIENT_SECRET_HERE
DISCORD_REDIRECT_URI=https://your-domain.example/auth/discord/callback
# Пароль для базы данных PostgreSQL
POSTGRES_PASSWORD=StrongPasswordForDatabase
# Локальный порт веб-интерфейса GM-Relay
GMRELAY_WEB_PORT=8080
# === Backup ===
# Сколько дней хранить дампы PostgreSQL (default: 7)
BACKUP_RETENTION_DAYS=7
# Имя Docker volume для резервных копий БД
BACKUP_VOLUME_NAME=game_pgbackups
# Имя Docker volume для обложек портфолио (загружаемых мастерами)
PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers
+133 -27
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 1.1.0
VERSION: 3.9.3
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
@@ -23,33 +23,104 @@ jobs:
username: toutsu
password: ${{ secrets.GIT_TOKEN }}
- name: Build and push Bot
uses: docker/build-push-action@v5
with:
context: .
file: src/GmRelay.Bot/Dockerfile
push: true
tags: |
git.codeanddice.ru/toutsu/gmrelay-bot:latest
git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
labels: |
org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}
- name: Build Bot image
run: |
docker build \
--label "org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}" \
-f src/GmRelay.Bot/Dockerfile \
-t git.codeanddice.ru/toutsu/gmrelay-bot:latest \
-t git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }} \
.
- name: Build and push Web
uses: docker/build-push-action@v5
with:
context: .
file: src/GmRelay.Web/Dockerfile
push: true
tags: |
git.codeanddice.ru/toutsu/gmrelay-web:latest
- name: Push Bot image
run: |
docker push git.codeanddice.ru/toutsu/gmrelay-bot:latest
docker push git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
- name: Build Discord Bot image
run: |
docker build \
--label "org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}" \
-f src/GmRelay.DiscordBot/Dockerfile \
-t git.codeanddice.ru/toutsu/gmrelay-discord-bot:latest \
-t git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }} \
.
- name: Push Discord Bot image
run: |
docker push git.codeanddice.ru/toutsu/gmrelay-discord-bot:latest
docker push git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }}
- name: Build Web image
run: |
docker build \
--label "org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}" \
-f src/GmRelay.Web/Dockerfile \
-t git.codeanddice.ru/toutsu/gmrelay-web:latest \
-t git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }} \
.
- name: Push Web image
run: |
docker push git.codeanddice.ru/toutsu/gmrelay-web:latest
docker push git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }}
# ЧАСТЬ 1.5: Сканируем собранные образы на уязвимости
scan-images:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Install Trivy
run: |
# Install Trivy from the official Docker image instead of the
# upstream install.sh. Rationale:
# 1. install.sh resolves the positional tag against the
# GitHub releases API; when a release is unpublished or
# yanked, the script fails with
# `unable to find '<tag>' - use 'latest' or see ...`
# even when the release once existed. We hit this with
# v0.71.0.
# 2. Docker Hub tags are content-addressed and rarely
# removed, so a pinned image tag is much more stable.
# 3. The image is multi-arch (linux/amd64, linux/arm64,
# linux/ppc64le, linux/s390x) so the same tag works on
# the GitHub-hosted runner and on the ARM64 Pi runner.
set -euo pipefail
TRIVY_VERSION="0.70.0"
docker pull --quiet "aquasec/trivy:${TRIVY_VERSION}"
docker create --name trivy-tmp "aquasec/trivy:${TRIVY_VERSION}"
docker cp trivy-tmp:/usr/local/bin/trivy /usr/local/bin/trivy
docker rm trivy-tmp >/dev/null
chmod +x /usr/local/bin/trivy
trivy --version
- name: Scan Bot image
run: |
trivy image \
--severity HIGH,CRITICAL \
--exit-code 1 \
--format table \
git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
- name: Scan Discord Bot image
run: |
trivy image \
--severity HIGH,CRITICAL \
--exit-code 1 \
--format table \
git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }}
- name: Scan Web image
run: |
trivy image \
--severity HIGH,CRITICAL \
--exit-code 1 \
--format table \
git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }}
labels: |
org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}
# ЧАСТЬ 2: Запускаем эти образы на самом сервере
deploy:
needs: build-and-push
needs: scan-images
runs-on: ubuntu-latest # Тот же локальный раннер
steps:
- name: Checkout repository
@@ -59,15 +130,50 @@ jobs:
run: |
echo "TELEGRAM_BOT_TOKEN=${{ secrets.TELEGRAM_BOT_TOKEN }}" > .env
echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env
echo "DISCORD_BOT_TOKEN=${{ secrets.DISCORD_BOT_TOKEN }}" >> .env
echo "TELEGRAM_BOT_USERNAME=${{ secrets.TELEGRAM_BOT_USERNAME }}" >> .env
echo "TELEGRAM_MINI_APP_URL=${{ secrets.TELEGRAM_MINI_APP_URL }}" >> .env
echo "DISCORD_CLIENT_ID=${{ secrets.DISCORD_CLIENT_ID }}" >> .env
echo "DISCORD_CLIENT_SECRET=${{ secrets.DISCORD_CLIENT_SECRET }}" >> .env
echo "DISCORD_REDIRECT_URI=${{ secrets.DISCORD_REDIRECT_URI }}" >> .env
- name: Deploy Containers
run: |
# Авторизуемся локальным докером в нашей Gitea
docker login git.codeanddice.ru/ -u toutsu -p ${{ secrets.GIT_TOKEN }}
# Pull гарантирует, что мы получили нужную версию.
docker compose pull bot web
docker compose pull bot discord web
# Запускаем! Флаг -d оставит их работать в фоне.
docker compose up -d
docker compose up -d
# Ждём, пока сервисы перейдут в healthy или упадут
SERVICES="bot discord web"
MAX_WAIT=40
INTERVAL=5
ELAPSED=0
while [ $ELAPSED -lt $MAX_WAIT ]; do
NOT_HEALTHY=0
for svc in $SERVICES; do
HEALTH=$(docker compose ps $svc --format="{{.Health}}" 2>/dev/null | head -n1)
if [ "$HEALTH" != "healthy" ]; then
STATE=$(docker compose ps $svc --format="{{.State}}" 2>/dev/null | head -n1)
echo "❌ $svc not healthy yet (state: ${STATE:-unknown})"
NOT_HEALTHY=$((NOT_HEALTHY + 1))
fi
done
if [ $NOT_HEALTHY -eq 0 ]; then
echo "✅ All services are healthy!"
exit 0
fi
sleep $INTERVAL
ELAPSED=$((ELAPSED + INTERVAL))
done
echo "⏰ Timed out waiting for services to become healthy"
docker compose ps
exit 1
+93
View File
@@ -0,0 +1,93 @@
name: PR Checks
on:
pull_request:
branches:
- main
jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Verify Trivy dependency scan inputs
run: |
lock_count="$(find . -name packages.lock.json -not -path "*/bin/*" -not -path "*/obj/*" | tee trivy-targets.txt | wc -l)"
echo "Trivy NuGet lock files: ${lock_count}"
if [ "${lock_count}" -eq 0 ]; then
echo "::error::No packages.lock.json files found. Trivy would scan 0 NuGet dependency files."
exit 1
fi
# ── Linting ──
- name: Lint C# code style
run: dotnet format --verify-no-changes --verbosity diagnostic
# ── Security ──
- name: Check NuGet packages for vulnerabilities
run: |
dotnet list package --vulnerable --include-transitive 2>&1 | tee nuget-audit.txt
if grep -qi "has the following vulnerable packages" nuget-audit.txt; then
echo "::error::Vulnerable NuGet packages found!"
exit 1
fi
echo "No vulnerable packages detected."
- name: Install Trivy
run: |
# Install Trivy from the official Docker image instead of the
# upstream install.sh. Rationale (see deploy.yml for the long
# version): the GitHub release tag we pinned (v0.71.0) was
# unpublished, and install.sh fails hard on missing tags.
# Docker Hub images are content-addressed and rarely removed,
# and the multi-arch manifest covers linux/amd64 + linux/arm64.
set -euo pipefail
TRIVY_VERSION="0.70.0"
docker pull --quiet "aquasec/trivy:${TRIVY_VERSION}"
docker create --name trivy-tmp "aquasec/trivy:${TRIVY_VERSION}"
docker cp trivy-tmp:/usr/local/bin/trivy /usr/local/bin/trivy
docker rm trivy-tmp >/dev/null
chmod +x /usr/local/bin/trivy
trivy --version
- name: Trivy filesystem security scan
run: |
set +e
trivy fs --scanners vuln,misconfig,secret --exit-code 1 --severity HIGH,CRITICAL . 2>&1 | tee trivy-scan.log
trivy_exit="${PIPESTATUS[0]}"
if ! grep -Eq "Number of language-specific files[[:space:]]+num=[1-9][0-9]*" trivy-scan.log; then
echo "::error::Trivy did not detect any language-specific dependency files."
exit 1
fi
exit "${trivy_exit}"
# ── Build (includes SAST via SecurityCodeScan Roslyn analyzer) ──
- name: Build Shared
run: dotnet build src/GmRelay.Shared/GmRelay.Shared.csproj --no-restore
- name: Build Bot (compile check, includes SAST)
run: dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj --no-restore
- name: Build Discord Bot (compile check, includes SAST)
run: dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore
- name: Build Web (compile check, includes SAST)
run: dotnet build src/GmRelay.Web/GmRelay.Web.csproj --no-restore
# ── Tests ──
- name: Run tests
run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal
+22
View File
@@ -12,6 +12,8 @@ publish/
.vscode/
.idea/
.gemini/
.claude/
.serena/
*.swp
*~
*.DotSettings.user
@@ -26,3 +28,23 @@ TestResults/
## Secrets
appsettings.*.local.json
.env
## AI working directories
docs/superpowers/
docs/plans/
*.diff
# OpenCode / Mavis / Playwright scratch dirs
.opencode/
.mavis/
.playwright-mcp/
.superpowers/
# Local screenshots / diagnostic artifacts
.club-*.png
.showcase-*.png
showcase-*.png
*.png.local
deploy_log.txt
test_output.txt
src/GmRelay.Bot/Dockerfile.test
+144
View File
@@ -0,0 +1,144 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build, Test, and Development Commands
This is a .NET 10 solution using the modern XML-based `.slnx` format. The global SDK version is `10.0.100` with `rollForward: latestFeature`.
**Build the solution:**
```bash
dotnet build
```
**Build individual projects (the CI does this to include SAST via SecurityCodeScan):**
```bash
dotnet build src/GmRelay.Shared/GmRelay.Shared.csproj --no-restore
dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj --no-restore
dotnet build src/GmRelay.Web/GmRelay.Web.csproj --no-restore
```
**Run all tests:**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal
```
**Run a single test class or method:**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~YourTestClassName"
```
**Lint and format:**
```bash
dotnet format --verify-no-changes --verbosity diagnostic # CI enforcement
dotnet format # Apply fixes
```
**Check for vulnerable packages:**
```bash
dotnet list package --vulnerable --include-transitive
```
**Restore with lock file verification:**
The repo enforces `RestorePackagesWithLockFile=true`. After adding or updating packages, commit the updated `packages.lock.json` files or the Trivy scan in CI will fail.
**Run locally with Aspire (dev orchestration):**
```bash
dotnet run --project src/GmRelay.AppHost/GmRelay.AppHost.csproj
```
This automatically starts PostgreSQL in a container, the Bot, and the Web dashboard.
**Run locally with Docker Compose (production-like):**
```bash
cp .env.example .env
# Edit .env with your TELEGRAM_BOT_TOKEN, TELEGRAM_BOT_USERNAME, POSTGRES_PASSWORD
docker compose up -d
```
## High-Level Architecture
### Project Roles and Runtime Model
| Project | Runtime | Key Trait |
|---|---|---|
| `GmRelay.Bot` | `Microsoft.NET.Sdk.Worker` | **Native AOT** binary. Telegram long polling bot + stateless scheduler. |
| `GmRelay.Web` | `Microsoft.NET.Sdk.Web` | Blazor Server dashboard. Cookie auth via Telegram Login Widget / Mini App `initData`. |
| `GmRelay.Shared` | Plain library | Domain models and platform-neutral view builders. **Must not depend on `Telegram.Bot`**. |
| `GmRelay.ServiceDefaults` | Aspire shared project | OpenTelemetry, health checks, HTTP resilience. Referenced by both Bot and Web. |
| `GmRelay.AppHost` | Aspire orchestrator | Dev-only. Spins up PostgreSQL and wires Bot + Web with service discovery. |
**Important:** `README.md` references `GmRelay.Migrator` and `GmRelay.Worker`, but these projects do not exist. Migrations (`DbUp`) and background workers (`BackgroundService`) live inside `GmRelay.Bot`.
### Vertical Slice Architecture with Explicit DI
Each use case is a self-contained vertical slice: a C# record (Command/Query) + Handler class with all logic (SQL, Telegram API calls, validation). There are no abstract repository interfaces or service layers.
Because the Bot is compiled as Native AOT (`PublishAot=true`, `EnableTrimAnalyzer=true`), **all DI registrations are explicit** in `src/GmRelay.Bot/Program.cs`. There is no assembly scanning or reflection-based discovery. When adding a new handler, you must register it manually in Program.cs.
### Database Access: Npgsql + Dapper.AOT + DbUp
**No EF Core** — it is incompatible with Native AOT. The stack is:
- **Npgsql** ADO.NET for connections.
- **Dapper 2.1.72** with **Dapper.AOT 1.0.48** for compile-time source-generated mapping (AOT-safe).
- **DbUp 7.0.1** for migrations. SQL scripts are embedded resources in `src/GmRelay.Bot/Migrations/` (V001 through V015).
- `DbMigrator.MigrateUp()` runs on every Bot startup.
Both Bot and Web share the same PostgreSQL database. Web registers `NpgsqlDataSource` via `builder.AddNpgsqlDataSource("gmrelaydb")` (Aspire integration), while Bot registers it manually to avoid reflection-based Aspire configuration at AOT time.
### Platform-Neutral Rendering (ADR-002)
Rendering is split into two stages:
1. **View Builder** (`GmRelay.Shared`) — platform-agnostic view model from domain DTOs.
2. **Platform Renderer**`TelegramSessionBatchRenderer` lives in both `GmRelay.Bot` and `GmRelay.Web` (temporary duplication until a third Telegram consumer justifies extracting `GmRelay.Shared.Telegram`).
This means `GmRelay.Shared` must remain free of `Telegram.Bot` types. If you need to add rendering logic that produces `InlineKeyboardMarkup`, it belongs in the Bot or Web project, not Shared.
### Stateless Scheduling
The session scheduler (`SessionSchedulerService`) is a `BackgroundService` with a `PeriodicTimer(TimeSpan.FromMinutes(1))`. On each tick it queries PostgreSQL for sessions needing action (T-24h confirmation, T-5min join link) and updates their status. There is no in-memory state — the database is the single source of truth. This design was chosen specifically because Quartz.NET is incompatible with Native AOT.
### Health Checks
- **Bot:** Custom `BotHealthCheckHostedService` listens on port 8081. The Docker health check hits `localhost:8081/health`.
- **Web:** Standard ASP.NET Core health checks on `/health` (JSON response with status and timestamp) and `/alive` (liveness probe tag filter). Exposed via `GmRelay.ServiceDefaults`.
### Authentication and Security
- **Telegram Login Widget** and **Mini App `initData`** verification via HMAC-SHA256. Cookie auth is hardened (`HttpOnly`, `SecurePolicy.Always`, `SameSite.Strict`).
- Web Data Protection keys are persisted to `/app/dataprotection-keys` (Docker volume `web_keys`).
- Security headers middleware (`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`) is applied globally in Web.
- `SecurityCodeScan.VS2019` (5.6.7) is included in all projects via `Directory.Build.props` for SAST at build time.
- Connection string passwords are redacted in logs via `SecretRedactor`.
### CI/CD Pipeline
`.gitea/workflows/pr-checks.yml` runs on every PR to `main`:
1. `dotnet restore`
2. Verify `packages.lock.json` files exist for Trivy
3. `dotnet format --verify-no-changes`
4. `dotnet list package --vulnerable`
5. Trivy filesystem scan (`vuln,misconfig,secret`, HIGH/CRITICAL)
6. Build Shared → Bot → Web
7. Run tests
`.gitea/workflows/deploy.yml` runs on push to `main`:
1. Build and push `gmrelay-bot` and `gmrelay-web` images to `git.codeanddice.ru/toutsu/...`
2. Trivy image scan on both images (HIGH/CRITICAL, exit-code 1)
3. Create `.env` from secrets and run `docker compose up -d`
### Environment Configuration
Key environment variables (see `.env.example`):
- `TELEGRAM_BOT_TOKEN`, `TELEGRAM_BOT_USERNAME`, `TELEGRAM_MINI_APP_URL`
- `POSTGRES_PASSWORD`
- `GMRELAY_WEB_PORT` (default 8080)
- `ConnectionStrings__gmrelaydb` — used by both Bot and Web
The Bot reads config as `Telegram:BotToken` (colon) which maps from `Telegram__BotToken` (double underscore) via environment variables.
### Docker Images
- **Bot:** Multi-stage Dockerfile. Build stage uses `sdk:10.0-noble` with `clang` and `zlib1g-dev` for AOT compilation. Final stage uses `runtime-deps:10.0-noble`. Exposes 8081.
- **Web:** Multi-stage Dockerfile. Build stage uses `sdk:10.0-noble`. Final stage uses `aspnet:10.0-noble` with `libgssapi-krb5-2` and `wget`. Exposes 8080.
Both images are built for multi-arch (`linux/amd64`, `linux/arm64`) to support Raspberry Pi 5 (ARM64) deployment.
+6 -1
View File
@@ -1,10 +1,15 @@
<Project>
<PropertyGroup>
<Version>1.1.0</Version>
<Version>3.9.3</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SecurityCodeScan.VS2019" Version="5.6.7" PrivateAssets="all" />
</ItemGroup>
</Project>
+1
View File
@@ -2,6 +2,7 @@
<Folder Name="/src/">
<Project Path="src/GmRelay.AppHost/GmRelay.AppHost.csproj" />
<Project Path="src/GmRelay.Bot/GmRelay.Bot.csproj" />
<Project Path="src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj" />
<Project Path="src/GmRelay.ServiceDefaults/GmRelay.ServiceDefaults.csproj" />
<Project Path="src/GmRelay.Web/GmRelay.Web.csproj" />
</Folder>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Toutsu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
+23
View File
@@ -0,0 +1,23 @@
# Discord /newsession и /listsessions — Issue #28
## Что реализовано
- Slash-команда /newsession для создания игровых сессий прямо из Discord.
- Slash-команда /listsessions для просмотра предстоящих игр в сервере.
- DiscordPermissionChecker — проверка прав (owner / admin / manager).
- DiscordPlatformMessenger — реализация IPlatformMessenger для Discord (NetCord REST).
- Полная интеграция в DI (Program.cs).
## Архитектура
- Vertical slice: каждая команда — отдельный файл (Command + Handler).
- Platform-agnostic SQL: используются колонки platform, external_group_id, external_user_id.
- Рендеринг переиспользует существующий DiscordSessionBatchRenderer.
## TDD
- 212 тестов, все зелёные.
- Source-level тесты проверяют паттерны: Dapper, Npgsql, транзакции, CancellationToken, платформенную нейтральность.
## Версия
- Minor bump: 2.3.0 → 2.4.0
- Синхронизировано: Directory.Build.props, compose.yaml, deploy.yml, NavMenu.razor.
Closes #28
+184 -89
View File
@@ -1,136 +1,231 @@
# 🎲 GM-Relay: TTRPG Session Scheduling Bot & Web Dashboard
**GM-Relay** — это комплексное решение для Мастеров Подземелий (ГМов), состоящее из высокопроизводительного Telegram-бота и удобного веб-интерфейса. Предназначено для автоматизации записи игроков на сессии, управления расписанием и проведения игр.
**GM-Relay** — это комплексное решение для Мастеров Подземелий (ГМов), состоящее из высокопроизводительного Telegram-бота, Discord worker и удобного веб-интерфейса. Предназначено для автоматизации записи игроков на сессии, управления расписанием и проведения игр.
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v3.6.0`.
---
## ✨ Ключевые возможности
## ✨ Key Features
### 🤖 Telegram Бот
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед).
- **✋ Интерактивная запись**: Игроки записываются на конкретные даты нажатием одной кнопки.
- **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр.
- **❌ Управление сессиями**: Мастер может отменять отдельные игры прямо в общем сообщении расписания.
- **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой.
- **🚀 Native AOT**: Скомпилирован в нативный бинарный файл. Мгновенный запуск и минимальное потребление памяти. Идеально для **Raspberry Pi**.
### 🤖 Telegram Bot
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением изменения (на недельный месяц в перед).
- **🖼 Обложки расписаний**: И batch-посту можно прикрепить фото к `/newsession` или указать строку `Картинка: https://...`; бот отправит обложку перед сообщением записи.
- **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch.
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему.
- **📁 Поддержка Форумов (Telegram Topics)**: Если `/newsession` запущен в теме форума Telegram, расписание и групповые уведомления остаются в этой теме; при запуске из корня форума бот создает отдельную тему и сообщает о необходимости прав admin/Manage Topics, если их не хватает.
- **❌ Управление сессиями**: Owner и назначенные co-GM могут создавать, отменять, удалять и переносить игры из Telegram через `/listsessions`; публичный пост записи показывает только кнопки игроков.
- **🔄 Голосование за перенос**: Быстрый поиск свободного места с через свободное недель и кнопками новых времени и дедлайном.
- **🔔 Уведомления**: Игрок получают за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются.
- **🕐 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`.
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в подключенных Telegram- и Discord-каналах.
### Discord Bot
- **Slash-команды `/newsession` и `/listsessions`**: GM создаёт сессии и публикует актуальное расписание прямо в Discord-канале.
- **Кнопки Join/Leave с ephemeral-ответами**: игроки нажимают Join/Leave в Discord-сообщении; бот отвечает ephemeral-сообщением и обновляет schedule message.
- **RSVP (подтверждения) за 24ч до сессии**: scheduler публикует запрос подтверждения в Discord-канале, игроки отвечают кнопками, а GM получает итоги RSVP.
- **DM-напоминания за 1ч и ссылки перед игрой**: one-hour reminders и join-link notifications отправляются в Discord DM при включённых личных уведомлениях; сбои DM логируются без публичного fallback.
- **Reschedule voting (голосование за перенос)**: deadline-сервис обновляет Discord vote message и schedule message через `IPlatformMessenger`.
- **Лимиты и waitlist**: при заполненном составе игрок попадает в waitlist, а при выходе участника первый ожидающий автоматически продвигается в основной состав.
### 🌐 Web Dashboard (Blazor Server)
- **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация).
- **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов.
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков.
- **🕒 Управление временем**: UI адаптирован под московское время (UTC+3), в то время как база данных работает в UTC.
- **🔐 Авторизация через Telegram**: Telegram Login Widget с HMAC-SHA256 валидацией.
- **📱 Telegram Mini App Dashboard**: Мобильная панель открывается из Telegram, проверяет `initData` на сервере, учитывает safe-area телефона и верхнюю панель Telegram.
- **✏️ Редактирование**: Детальное изменение дат, названий и статусов сессий.
- **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**.
- **🌍 Публичные страницы клубов**: Owner и co-GM включают read-only страницу `/club/{slug}` и отдельные ссылки `/s/{sessionId}` только для опубликованных сессий; состав игроков и приватные join-ссылки не показываются.
- **🧑‍🏫 Публичные профили мастеров**: мастер управляет профилем из `/profile`, публикует описание на `/gm/{slug}`, а публичные клубы, игры и каталог ссылаются на профиль без раскрытия platform identifiers.
- **📚 Портфолио завершённых приключений**: Owner и co-GM собирают завершённые сессии в портфолио-игры на странице `/group/{id}/portfolio`, привязывают ссылки на прошедшие сессии и публикуют публичную страницу `/portfolio/{slug}` с обложкой, описанием, системой/форматом и составом мастеров.
- **⭐ Модерируемые отзывы игроков**: участники прошедших сессий могут оставить отзыв на `/portfolio/{slug}/review` с явным согласием на публикацию; мастера модерируют отзывы (`Approved`/`Rejected`/`Hidden`) в редакторе портфолио, и только одобренные отзывы видны публичной странице.
- **🖼 Обложки портфолио**: мастера загружают JPG/PNG/WEBP-обложки в редакторе портфолио; файлы сохраняются в Docker volume `portfolio_covers` и обслуживаются по пути `/portfolio-covers/{storageKey}`; конфигурация пути — `PortfolioCovers__StoragePath` в `compose.yaml`.
- **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона.
- **📦 Bulk-операции для Batch Sessions**:
- обновить общий `title`/`link` у всей пачки;
- перенести пачку на фиксированный шаг в днях;
- клонировать batch на следующую неделю или месяц.
- **⬆️ Управление очередью**: Заполненность, лист ожидания и ручное повышение игрока из очереди.
- **📜 История изменений сессий**: Страница `/session/{id}/history` показывает аудит-лог всех значимых изменений (время, ссылка, название, участники, статус) с указанием акторов и дат.
- **📊 Статистика посещаемости**: Страница `/group/{id}/stats` показывает долю присутствия, количество пропусков и среднюю явку по каждому игроку группы.
- **🔄 Автосинхронизация**: Изменения в вебе мгновенно перерисовывают platform message расписания через `IPlatformMessenger`.
---
## 🛠 Технологический стек
- **Язык**: C# 14 (.NET 10)
- **Архитектура**: Vertical Slice Architecture, общая библиотека (`GmRelay.Shared`) для доменной логики.
- **Бот**: Telegram.Bot, Native AOT.
- **Веб-интерфейс**: Blazor Server.
- **Оркестрация**: .NET Aspire (`GmRelay.AppHost`).
- **База данных**: PostgreSQL
- **ORM**: Dapper (с использованием Dapper.AOT для source generators).
- **Миграции**: DbUp.
- **Развертывание**: Docker Compose + Multi-arch (AMD64/ARM64).
| Компонент | Технология |
|---|---|
| Язык | C# 14 (.NET 10) |
| Архитектура | Vertical Slice + общая библиотека `GmRelay.Shared` |
| Боты | Telegram.Bot (**Native AOT**), NetCord Gateway (Discord worker внутри `GmRelay.Bot`) |
| Веб | Blazor Server |
| Оркестрация | .NET Aspire (`GmRelay.AppHost`) |
| БД | PostgreSQL |
| ORM | Dapper + **Dapper.AOT** (source generators) |
| Миграции | DbUp |
| Развёртывание | Docker Compose, Multi-arch (**AMD64/ARM64**) |
> [!NOTE]
> При использовании Dapper в режиме Native AOT все SQL-запросы используют строго типизированные DTO; динамические типы (`dynamic`) не поддерживаются.
---
## 🚀 Быстрый старт (Docker Compose)
Проект использует Docker Compose для одновременного запуска базы данных, бота и веб-интерфейса.
### 1. Подготовка
Убедитесь, что у вас установлены **Docker** и **Docker Compose**.
### 2. Настройка окружения
Скопируйте файл-шаблон и заполните его значениями:
**Требования:** Docker и Docker Compose.
### 1. Настройка окружения
```bash
cp .env.example .env
```
Отредактируйте `.env`:
**Ключевые переменные `.env`:**
```env
# Токен вашего бота от @BotFather (используется и для бота, и как секретный ключ для веб-авторизации)
# Токен от @BotFather (используется ботом и как секретный ключ веб-авторизации)
TELEGRAM_BOT_TOKEN=ваш_токен_здесь
# Имя вашего бота в Telegram (без @), например: GmRelayBot.
# Найти его можно в информации о боте у @BotFather.
# Используется для работы виджета авторизации (Telegram Login Widget).
# Токен Discord application bot
DISCORD_BOT_TOKEN=ваш_discord_токен_здесь
# Discord OAuth (для Web Dashboard)
DISCORD_CLIENT_ID=ваш_discord_client_id_здесь
DISCORD_CLIENT_SECRET=ваш_discord_client_secret_здесь
DISCORD_REDIRECT_URI=https://your-domain.example/auth/discord/callback
# Имя бота без @ (для Telegram Login Widget)
TELEGRAM_BOT_USERNAME=ваше_имя_бота_здесь
# Пароль для базы данных PostgreSQL
# HTTPS URL Mini App, например https://your-domain.example/miniapp
TELEGRAM_MINI_APP_URL=https://your-domain.example/miniapp
POSTGRES_PASSWORD=ваш_надежный_пароль
GMRELAY_WEB_PORT=8080
```
*(Опционально)* Настройте домен Telegram бота в @BotFather командой `/setdomain` для работы виджета авторизации на вашем сайте.
**Настройка в @BotFather:**
- Команда `/setdomain` для работы виджета авторизации на вашем домене.
- Для Mini App настройте домен Web Dashboard и menu button на URL из `TELEGRAM_MINI_APP_URL`.
- Начиная с **v1.9.3** дополнительных действий для фикса входа не требуется: fallback выполняется внутри активного Telegram WebView по тому же HTTPS-адресу `/miniapp`.
### 3. Запуск
Выполните команду:
### 2. Запуск
```bash
docker compose up -d -build
```
Инфраструктура автоматически:
- Поднимет PostgreSQL.
- Запустит бота (применив миграции БД).
- Запустит веб-интерфейс (доступен по умолчанию на порту **8080** внутри контейнера).
---
## ⚙️ Настройка бота в Telegram
Чтобы бот работал корректно:
1. **Добавьте бота в группу** (или Супергруппу/Форум).
2. **Назначьте бота Администратором**.
3. **Необходимые права**:
* `Выбор тем` (Managed Topics) — **обязательно** для Форумов.
* `Отправка сообщений`.
* `Закрепление сообщений` — рекомендуется.
> [!TIP]
> Колонку "Мастер" (GM) бот определяет по первому человеку, который создал сессию в этой группе. Только этот пользователь сможет отменять игры через кнопки бота и редактировать их в веб-интерфейсе.
---
## 📝 Инструкция для Мастера
### Создание расписания игр
Используйте команду `/newsession` с описанием в следующем формате:
```text
/newsession
Название: Легенды Берега Мечей (D&D 5e)
Время: 15.05.2024 19:30
Время: 22.05.2024 19:00
Ссылка: https://discord.gg/invite-link
docker compose up -d
```
### Другие команды
- `/listsessions` — Показать список всех актуальных игр в этой группе.
- `/reschedulesession` — Перенести сессию на другое время с голосованием игроков.
- `/deletesession` — Удалить сессию.
- `/exportcalendar` — Получить `.ics` файл с играми.
- `/help` — Справка по формату.
**Автоматически выполняется:**
- создание Docker-сети и volume PostgreSQL;
- подъём PostgreSQL (`db:5432`);
- запуск бота с плавной миграцией (DbUp);
- запуск Discord Gateway worker на NetCord (healthcheck на `:8082`);
- запуск веб-приложения с подключением к БД и Telegram API.
### 3. Первоначальная настройка
1. Напишите боту `/start`.
2. Создайте группу через `/newgroup`.
3. Откройте Mini App или Web Dashboard для расширенного управления.
4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`. Скопируйте `DISCORD_BOT_TOKEN` в `.env`; `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` и `DISCORD_REDIRECT_URI` нужны только для входа в Web Dashboard через Discord.
5. Перезапустите Docker Compose (`docker compose up -d`), а затем в Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`; игроки записываются и выходят кнопками в опубликованном сообщении.
## 📚 Портфолио завершённых приключений
Начиная с **v3.6.0** ГМы могут публиковать завершённые кампании в виде постоянных портфолио-страниц с обложкой, описанием, системой/форматом, составом мастеров и модерируемыми отзывами игроков.
### Возможности
- **Управление портфолио** — в `/group/{id}/portfolio` владелец и co-GM создают портфолио-игры из прошедших сессий, выбирают мастеров, заполняют описание, загружают обложку и публикуют по `public_slug`.
- **Публичная страница `/portfolio/{slug}`** — read-only карточка приключения с обложкой, описанием, составом мастеров (только публичные профили) и одобренными отзывами.
- **Отзывы участников** — на `/portfolio/{slug}/review` аутентифицированные игроки, чьи идентификаторы участвовали в одной из привязанных сессий без пометки GM, отправляют отзыв с явным согласием на публикацию; один отзыв на игрока, повторная отправка запрещена.
- **Модерация отзывов** — на странице редактора портфолио владелец/co-GM видит очередь `Pending` и переводит отзывы в `Approved`, `Rejected` или `Hidden`; только `Approved` отзывы попадают в публичную выдачу.
- **Публикация под требования** — портфолио-игра публикуется только при заполненном slug, описании, обложке, минимум одной завершённой сессии и хотя бы одном мастере группы.
### Хранение обложек
Загруженные обложки хранятся в Docker volume `portfolio_covers` (по умолчанию имя `gmrelay_portfolio_covers`), обслуживаются веб-приложением по пути `/portfolio-covers/{storageKey}` с кешированием `Cache-Control: public, max-age=31536000, immutable`.
В `.env` можно переопределить имя volume:
```env
PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers
```
В `compose.yaml` это значение пробрасывается в сервис `web` через `volumes.portfolio_covers.name`; путь к каталогу внутри контейнера — `/app/portfolio-covers` (настраивается через `PortfolioCovers__StoragePath`).
Хранилище инкапсулировано интерфейсом `IPortfolioCoverStorage` с реализацией `LocalPortfolioCoverStorage` (файловая система), что оставляет границу для замены на S3-совместимое хранилище без изменения кода портфолио-сервисов.
## 💾 Backup и восстановление
Проект включает автоматический ежедневный backup PostgreSQL через сервис `db-backup` в Docker Compose.
### Как это работает
- **Каждый день в 03:00** выполняется `pg_dump` базы `gmrelay_db`.
- Дампы сжимаются (`gzip`) и сохраняются в volume `pgbackups` (`/backups`).
- Формат имени: `gmrelay_db_YYYYMMDD_HHMMSS.sql.gz`.
- Ротация: по умолчанию хранятся последние **7 дней** (настраивается через `BACKUP_RETENTION_DAYS`).
### Проверка бэкапов
```bash
docker compose exec db-backup ls -la /backups
```
### Ручное создание дампа
```bash
docker compose exec db-backup sh -c "pg_dump -h db -U gmrelay -d gmrelay_db | gzip > /backups/gmrelay_db_manual.sql.gz"
```
### Восстановление из бэкапа
```bash
# Использовать последний автоматический бэкап
./scripts/restore.sh
# Или указать конкретный файл
./scripts/restore.sh backups/gmrelay_db_20260512_030000.sql.gz
```
> [!WARNING]
> Восстановление **перезаписывает текущую базу данных**. Убедитесь, что вы понимаете последствия, прежде чем запускать `restore.sh`.
### Переменные окружения (опциональные)
```env
BACKUP_RETENTION_DAYS=7
BACKUP_VOLUME_NAME=game_pgbackups
```
---
## 🏗 Разработка и запуск локально (.NET Aspire)
## 🗂 Структура репозитория
Для локальной разработки проще всего использовать .NET Aspire:
```
├── src/
│ ├── GmRelay.AppHost/ # .NET Aspire orchestrator
│ ├── GmRelay.Bot/ # Telegram- и Discord-бот (Native AOT + NetCord Gateway worker)
│ ├── GmRelay.ServiceDefaults/ # Aspire service defaults
│ ├── GmRelay.Shared/ # Общие доменные модели
│ └── GmRelay.Web/ # Blazor Server dashboard
├── tests/
│ └── GmRelay.Bot.Tests/ # xUnit + NSubstitute
├── compose.yaml # Docker Compose (AMD64 + ARM64)
└── .env.example # Шаблон переменных окружения
```
1. Установите [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) и workload Aspire.
2. Откройте решение `GM-Relay.slnx`.
3. Установите переменные окружения (или user secrets) для `GmRelay.AppHost`.
4. Запустите проект `GmRelay.AppHost`. Aspire Dashboard запустится автоматически, предоставляя удобный мониторинг БД, бота и веб-интерфейса.
---
> [!NOTE]
> При использовании **Dapper** в режиме Native AOT, все SQL-запросы используют строго типизированные DTO. Динамические типы (`dynamic`) не поддерживаются.
## 👨‍💻 Для разработчиков
- **Архитектура**: проект следует Vertical Slice с явным DI. Подробности — в [ADR-001](docs/adr/0001-use-vertical-slice-native-aot-and-aspire.md) и [ADR-002](docs/adr/002-platform-neutral-batch-rendering.md).
- **Добавление обработчика**: из-за Native AOT все DI-регистрации выполняются вручную в `src/GmRelay.Bot/Program.cs` (assembly scanning не используется).
- **Миграции**: SQL-скрипты добавляются как embedded resources в `src/GmRelay.Bot/Migrations/` и применяются автоматически при старте бота через DbUp.
- **Тесты**: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal`
- **Сборка**: `dotnet build`
---
## 📜 Лицензия
Проект распространяется под лицензией MIT. Использование в некоммерческих целях приветствуется.
MIT License. См. [LICENSE](./LICENSE).
---
*Построено с ❤️ для TTRPG-сообщества.*
+99
View File
@@ -0,0 +1,99 @@
## 🐞 Patch 3.9.2 — Hotfix: club-picker молча падал на шаге «Видимость» (3.9.1 неполный)
В 3.9.1 был починен только `WizardDraftRepository` (самый частый путь). Тот же баг с `(CommandDefinition)`-оверлоадом Dapper остался в 4 клуб-пикерах / permission-локапах — Wizard доходил до шага «Видимость», и при выборе «Публичная в витрине клуба» / «Только для членов клуба» `PersistAndRenderAsync` дёргал `_messenger.GetOwnerClubsAsync``PlatformNotSupportedException``GameCreationWizard` глотал исключение → кнопка `ack` отправлялась с тостом «⚠️ Ошибка», но нового шага пользователь не видел. Privacy «не цеплялась».
### 🩹 Что починено
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs::GetOwnerClubsAsync``new CommandDefinition(...)` → прямой `QueryAsync<WizardClubOption>(sql, params)`.
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardMessenger.cs::GetOwnerClubsAsync` — то же.
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs::WizardClubLookup.LoadClubsAsync` — то же.
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordPermissionLookup.cs::LoadManagerUserIdsAsync` — то же.
- `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` — добавлен `<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Dapper.AOT</InterceptorsPreviewNamespaces>` (раньше был только в Shared и Bot). Без этого `Dapper.AOT`-генератор не сканировал DiscordBot, и `new CommandDefinition`-вызовы в DiscordBot падали бы в рантайме даже после фикса сигнатур.
- `src/GmRelay.DiscordBot/Program.cs` — добавлен `[module: Dapper.DapperAot]` (раньше только в Bot и Shared).
- `Directory.Build.props` / `compose.yaml` / `.gitea/workflows/deploy.yml` / `NavMenu.razor` — бамп 3.9.1 → 3.9.2.
- `tests/.../WizardDraftRepositoryAotShapeTests.cs` — расширены `ClubPickerAndPermissionLookups_ShouldNotUseCommandDefinition` на 4 inline-cases + опциональный `containingClass` для дизамбигуации одинаковых имён методов в DiscordWizardInteractionModule.
### ⚠️ Известные ограничения
- Web-проект не под NativeAOT (Blazor Server), там `Dapper.AOT` не подключён и используется обычный Dapper; регрессия его не касается.
### 🧪 Тесты
- 592/594 passed (2 pre-existing skipped), `dotnet format` clean, `dotnet build` 0 warnings/errors, AOT-генератор эмитит интерсепторы для всех 4 клуб-пикеров + `WizardDraftRepository` (всего 5 файлов: 4 в Bot/DiscordBot/DiscordBot + 1 в Shared).
## 🐞 Patch 3.9.1 — Hotfix: Telegram-визард мёртв после 3.9.0
Регрессия в `WizardDraftRepository` (NativeAOT). В Telegram **не реагировали кнопки** и **не создавались игры**, потому что Dapper.AOT 1.0.48 не генерирует интерсепторы для оверлоада `(CommandDefinition)` — рантайм падал в `CreateParamInfoGenerator``PlatformNotSupportedException` на каждом апдейте, `TelegramBotService` глотал исключение и апдейт терялся.
### 🩹 Что починено
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs` — все 4 метода переписаны с `new CommandDefinition(sql, params, cancellationToken: ct)` на прямой оверлоад `connection.QuerySingleOrDefaultAsync<WizardDraft>(sql, params)` (паттерн `JoinSessionHandler`). Dapper.AOT генерирует интерсепторы только для прямого оверлоада.
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs``CreatedAt` / `UpdatedAt` / `ExpiresAt` переведены с `DateTimeOffset` на `DateTime` (UTC). AOT RowFactory вызывает `reader.GetDateTime()` напрямую и не делает `DateTime → DateTimeOffset` конверсию.
- `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs`, `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs``DateTimeOffset.UtcNow``DateTime.UtcNow` в новых драфтах.
- `Directory.Build.props` / `compose.yaml` / `.gitea/workflows/deploy.yml` / `NavMenu.razor` — бамп 3.9.0 → 3.9.1.
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryAotShapeTests.cs` — 5 source-grep регрессионных тестов: ни один метод `WizardDraftRepository` не должен использовать `new CommandDefinition`, и три timestamp-свойства `WizardDraft` должны быть `DateTime` (не `DateTimeOffset`).
### ⚠️ Известные ограничения
- В `TelegramWizardMessenger.GetOwnerClubsAsync`, `DiscordWizardMessenger.GetOwnerClubsAsync`, `DiscordPermissionLookup.LoadManagerUserIdsAsync`, `DiscordWizardInteractionModule.GetOwnerClubsAsync` остаётся `new CommandDefinition`. Эти вызовы **падают на AOT так же**, как падал `WizardDraftRepository` в 3.9.0. Пользователь натыкается на это только когда выбирает «видимость = клуб/мемберы» и доходит до шага выбора клуба. Будет исправлено в 3.9.2 вместе с переводом `DiscordWizardInteractionModule` на прямые Dapper-оверлоады.
### 🧪 Тесты
- 588/590 passed (2 pre-existing skipped), `dotnet format` clean, `dotnet build` 0 warnings/errors, AOT-генератор эмитит 4 интерсептора + `RowFactory17<WizardDraft>` + `CommandFactory30<WizardDraft>`.
## 🎯 Minor 3.9.0 — Discord-визард создания игры/пула (issue #112)
Пошаговый сценарий создания одиночной игры или пула игр в Discord-чате, по аналогии с Telegram-визардом из 3.8.0. Платформо-нейтральная стейт-машина `GameCreationWizard` и контракт `IWizardMessenger` перенесены в `GmRelay.Shared`, чтобы обе платформы (Telegram/Discord) использовали один и тот же движок визарда.
### 🧩 Что вошло в релиз
**Платформо-нейтральный рефакторинг (GmRelay.Shared)**
- `Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs` — стейт-машина визарда (один источник правды для обеих платформ)
- `Features/Sessions/CreateSession/Wizard/IWizardMessenger.cs` — контракт мессенджера (edit/send/answer/getOwnerClubs)
- `Features/Sessions/CreateSession/Wizard/WizardInteraction.cs` — запись взаимодействия (OwnerId, Text, CallbackPayload, PhotoFileId, PhotoUrl, InteractionId)
- `Features/Sessions/CreateSession/Wizard/WizardAction.cs`, `WizardKeyboard.cs`, `WizardStepLimits.cs` — модель кнопок и лимитов
- `Features/Sessions/CreateSession/Wizard/WizardDraft.cs` — добавлено поле `Platform`
- `Migrations/V032__add_wizard_drafts_platform.sql``ALTER TABLE wizard_drafts ADD COLUMN platform TEXT NOT NULL DEFAULT 'Telegram'`
**Discord-адаптер (GmRelay.DiscordBot)**
- `Features/Sessions/Wizard/DiscordWizardCommand.cs` — slash-команда `/newsession-wizard` с проверкой owner/co-GM через `DiscordPermissionLookup`
- `Features/Sessions/Wizard/DiscordWizardStep.cs` — рендер 15 шагов в NetCord embed + buttons/StringSelectMenu/modals
- `Features/Sessions/Wizard/DiscordWizardMessenger.cs` — реализация `IWizardMessenger` через NetCord REST (edit с fallback на re-send при 401/403/404)
- `Features/Sessions/Wizard/DiscordWizardSubmitter.cs` — финализация с 3-retry циклом
- `Features/Sessions/Wizard/DiscordWizardContextStore.cs` — in-memory кэш контекста (guild/channel/messageId) для 15-минутного interaction token
- `Features/Sessions/Wizard/DiscordWizardInteractionModule.cs` — inbound handlers: 3 NetCord `ComponentInteractionModule<TContext>` (button/StringMenu/Modal) + `WizardInteractionDispatcher`
- `Features/Sessions/Wizard/DiscordPermissionLookup.cs` — DB-хелпер для `group_managers`
- `Program.cs` — DI-регистрации + 3 `AddComponentInteractions<TInteraction, TContext>`
**Тесты**
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/*` — обновлены под новый контракт
- `tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs` — 12 source-level smoke-тестов на структуру interaction module
### 🗺 Что это даёт
- Мастера (GM) могут пошагово создавать игры и пулы слотов прямо в Discord через slash-команду, кнопки, выпадающие меню и модальные окна.
- UX адаптирован под Discord (нативные components), а не скопирован из Telegram.
- Общая стейт-машина и валидация: Telegram и Discord визарды развиваются синхронно, баги фиксятся в одном месте.
- PickClub-шаг использует реальный SQL-запрос к `club_memberships` с фильтром по роли Owner/CoGm.
### 📦 Версия и деплой
- Версия обновлена до 3.9.0 (`NavMenu.razor`, `.gitea/workflows/deploy.yml`)
- Docker-образы будут тегированы `3.9.0` при пуше в `main`
- Миграция V032 применяется автоматически на старте Bot
## 🛠 Patch 2.4.0 — Discord /newsession и /listsessions
Реализованы slash-команды Discord для создания сессий и просмотра расписания без Web Dashboard.
### 🧩 Что вошло в релиз
- src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs — slash-команда /newsession с параметрами (title, time, seats, link)
- src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs — handler создания batch + session в БД
- 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 — проверка прав через Discord permissions bitflag (Administrator = 0x8)
- src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs — реализация IPlatformMessenger для Discord через NetCord REST
- src/GmRelay.DiscordBot/Program.cs — регистрация DI: handlers, permission checker, messenger
- ests/GmRelay.Bot.Tests/Discord/ — 20+ TDD-тестов на парсинг, права, структуру, DI, рендеринг
- Синхронизированы версии: Directory.Build.props, NavMenu.razor, compose.yaml, deploy.yml → 2.4.0
### 🗺 Что это даёт
- Мастера (GM) могут создавать сессии прямо из Discord, не заходя в Web.
- Участники сервера видят расписание через /listsessions.
- Единая PostgreSQL модель для Telegram и Discord — никакого дублирования данных.
### 📦 Версия и деплой
- версия обновлена до 2.4.0
- Docker-образы используют тег 2.4.0
+98 -20
View File
@@ -1,52 +1,130 @@
services:
db:
image: postgres:17-alpine
container_name: gmrelay_db
restart: always
environment:
POSTGRES_USER: gmrelay
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
POSTGRES_DB: gmrelay_db
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- gmrelay
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U gmrelay -d gmrelay_db" ]
interval: 3s
timeout: 3s
retries: 10
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.1.0
container_name: gmrelay_bot
restart: always
network_mode: host
db-backup:
image: postgres:17-alpine
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
- "ConnectionStrings__gmrelaydb=Host=127.0.0.1;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD}"
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN}"
POSTGRES_USER: gmrelay
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
POSTGRES_DB: gmrelay_db
PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-7}
volumes:
- pgbackups:/backups
networks:
- gmrelay
entrypoint: ["sh", "-c"]
command:
- |
cat > /usr/local/bin/backup.sh << 'EOF'
#!/bin/sh
set -e
TMPFILE="/tmp/backup_$$.sql"
pg_dump -h db -U gmrelay -d gmrelay_db > "$TMPFILE"
gzip "$TMPFILE"
mv "$TMPFILE.gz" "/backups/gmrelay_db_$(date +%Y%m%d_%H%M%S).sql.gz"
find /backups -name 'gmrelay_db_*.sql.gz' -type f -mtime +${BACKUP_RETENTION_DAYS} -delete
EOF
chmod +x /usr/local/bin/backup.sh
echo "0 3 * * * /usr/local/bin/backup.sh" | crontab -
crond -f
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.3
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}"
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
- "Telegram__MiniAppUrl=${TELEGRAM_MINI_APP_URL:-}"
networks:
- gmrelay
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8081/health || exit 1"]
interval: 10s
timeout: 5s
retries: 3
discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.3
restart: always
depends_on:
db:
condition: service_healthy
bot:
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
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8082/health || exit 1"]
interval: 10s
timeout: 5s
retries: 3
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:1.1.0
container_name: gmrelay_web
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.3
restart: always
network_mode: host
depends_on:
db:
condition: service_healthy
bot:
condition: service_healthy
environment:
- "ConnectionStrings__gmrelaydb=Host=127.0.0.1;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD}"
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN}"
- "Telegram__BotUsername=${TELEGRAM_BOT_USERNAME}"
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
- "Telegram__BotUsername=${TELEGRAM_BOT_USERNAME:?Set TELEGRAM_BOT_USERNAME in .env}"
- "Telegram__MiniAppUrl=${TELEGRAM_MINI_APP_URL:-}"
- "Discord__ClientId=${DISCORD_CLIENT_ID:-}"
- "Discord__ClientSecret=${DISCORD_CLIENT_SECRET:-}"
- "Discord__RedirectUri=${DISCORD_REDIRECT_URI:-}"
- "PortfolioCovers__StoragePath=/app/portfolio-covers"
ports:
- "${GMRELAY_WEB_PORT:-8080}:8080"
volumes:
- web_keys:/app/dataprotection-keys
- portfolio_covers:/app/portfolio-covers
networks:
- gmrelay
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
interval: 10s
timeout: 5s
retries: 3
volumes:
pgdata:
external: true
name: game_pgdata
name: ${POSTGRES_VOLUME_NAME:-game_pgdata}
web_keys:
name: gmrelay_web_keys
name: ${WEB_KEYS_VOLUME_NAME:-gmrelay_web_keys}
pgbackups:
name: ${BACKUP_VOLUME_NAME:-game_pgbackups}
portfolio_covers:
name: ${PORTFOLIO_COVERS_VOLUME_NAME:-gmrelay_portfolio_covers}
networks:
gmrelay:
driver: bridge
+153
View File
@@ -0,0 +1,153 @@
# Discord wizard adapter — issue #112
## Summary
Implemented the Discord side of the platform-neutral game/pool creation
wizard (`feat/issue-112-wizard-refactor`). Six adapter files in
`src/GmRelay.DiscordBot/Features/Sessions/Wizard/` plus one inbound
handler module turn the `/newsession-wizard` slash command into a
fully clickable step-by-step wizard with button, StringSelectMenu, and
modal handlers. The shared `GameCreationWizard` state machine in
`GmRelay.Shared` is the single source of truth — the Discord adapter
only translates between NetCord's interaction types and the
platform-neutral `WizardInteraction` record.
## Files
Adapter (all under `src/GmRelay.DiscordBot/Features/Sessions/Wizard/`):
- `DiscordWizardContextStore.cs``IWizardContextStore` interface +
thread-safe in-memory store. Keyed by `draft.Id`. Holds the
`(GuildId, ChannelId, MessageId, ThreadId?)` snapshot the messenger
needs to re-send a draft after a 15-minute interaction token
expires.
- `DiscordWizardStep.cs` — renderer for all 15 wizard steps. Returns
an embed + `IReadOnlyList<IMessageComponentProperties>` (mixes
`ActionRow` buttons with `StringMenu` select menus) and exposes
`BuildModal` for the 8 modal-collecting steps.
- `DiscordWizardMessenger.cs``IWizardMessenger` impl. Uses
`RestClient.SendMessageAsync` / `ModifyMessageAsync` (edit falls
back to re-send on 401/403/404). Toast replies are stashed in the
existing `DiscordInteractionReplyCache`.
- `DiscordWizardSubmitter.cs` — 3-retry finalize loop. Builds the
shared `CreateSessionCommand` and calls `CreateSessionHandler`;
on success edits the draft to "✅ Создано: N сессий", on failure
shows retry/cancel buttons.
- `DiscordWizardCommand.cs``/newsession-wizard` slash command.
Owner/co-GM check via `group_managers` (DiscordPermissionLookup).
- `DiscordPermissionLookup.cs` — small DB helper that loads
`group_managers` rows for a guild.
- `DiscordWizardInteractionModule.cs`**inbound handlers** (this
commit). Three NetCord `ComponentInteractionModule<TContext>`
shells (button / StringSelectMenu / modal) share one
`WizardInteractionDispatcher` that:
1. parses the custom-id tail (`btn:choice:<step>:<value>`,
`btn:cancel`, `btn:back`, `btn:create`, `btn:resume:continue`,
`btn:resume:restart`, `select:<step>`, `modal:<step>`);
2. loads the active draft via
`IWizardDraftRepository.GetActiveAsync("Discord", ownerId, ct)`;
3. routes the callback through the shared
`GameCreationWizard.HandleInteractionAsync` (or
`DiscordWizardSubmitter.SubmitAsync` for the `create` button);
4. opens a modal popup when the new step needs text input,
otherwise acks with a deferred message so Discord doesn't show
"Application did not respond".
DI / tests:
- `src/GmRelay.DiscordBot/Program.cs` — 7 singleton registrations
(`IWizardDraftRepository`, `IWizardContextStore`, `IWizardMessenger`,
`GameCreationWizard`, `DiscordWizardSubmitter`,
`WizardInteractionDispatcher`, `DiscordWizardButtonModule`,
`DiscordWizardStringMenuModule`, `DiscordWizardModalModule`) plus
3 `AddComponentInteractions<TInteraction, TContext>` calls
(Button, StringMenu, Modal).
- `tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs`
— 12 source-level structural smoke tests: handler classes exist,
all 3 derive from `ComponentInteractionModule<TContext>`, all 3
register `[ComponentInteraction("wizard")]`, the dispatcher
exposes `HandleButtonAsync` / `HandleStringMenuAsync` /
`HandleModalAsync`, all 5 callback kinds (`choice` / `back` /
`cancel` / `create` / `resume`) are routed, the dispatcher
invokes `GameCreationWizard.HandleInteractionAsync` and
`DiscordWizardSubmitter.SubmitAsync` on `create`, Program.cs
registers all 3 `AddComponentInteractions` and all 4 module
classes, draft lookup is by `GetActiveAsync("Discord", …)`, modal
walks `Components[0] → TextInput → .Value`, string menu reads
`SelectedValues[0]`.
## Custom-id wire format
| Interaction | Custom-id | Handler |
|------------------------|------------------------------------------|-------------------------------------------------|
| Choice button | `wizard:btn:choice:<step>:<value>` | Wizard's `ApplyChoice` |
| Back button | `wizard:btn:back` | Wizard's `ApplyBack` |
| Cancel button | `wizard:btn:cancel` | Wizard deletes draft + edits "❌ Мастер отменён" |
| Create button | `wizard:btn:create` | `DiscordWizardSubmitter.SubmitAsync` (3 retries) |
| Resume: continue | `wizard:btn:resume:continue` | Re-render current step via messenger |
| Resume: restart | `wizard:btn:resume:restart` | Delete draft, prompt to re-run |
| StringSelectMenu | `wizard:select:<step>` | Wizard's `ApplyChoice` (step, SelectedValues[0]) |
| Modal submit | `wizard:modal:<step>` | Wizard's `ApplyText` (Text = Component[0].Value) |
The wizard renderer (`DiscordWizardStep`) owns the prefix generation:
`wizard:btn:<step>:<value>`, `wizard:select:<step>`,
`wizard:modal:<step>`. The handlers match by the `wizard` prefix and
parse the rest.
## Acceptance
-`dotnet build` решения — 0 warnings, 0 errors
-`dotnet test` — 190/190 Discord+Wizard tests pass (2 pre-existing
skipped). 12 source-level smoke tests cover the interaction module.
-`dotnet format --verify-no-changes` — clean
- ✅ Pushed to `feat/issue-112-wizard-refactor` (commits `b81d865`,
`f095209`).
PR link: https://git.codeanddice.ru/Toutsu/GmRelayBot/pulls/new/feat/issue-112-wizard-refactor
## Open questions
- ~~**Club lookup at the PickClub step**~~ — **FIXED in commit `7cfb196`.**
`WizardClubLookup.LoadClubsAsync` now queries `group_managers`
directly via injected `NpgsqlDataSource` with the same
`Owner | CoGm` role filter the messenger uses. The dispatcher
reads the owner's real club list and renders them in the
StringSelectMenu. Build green, 12 source-level smoke tests still
pass.
- ~~**MaybeOpenModalAsync was a no-op in the previous commit**~~ — **FIXED in commit `f095209`.**
The dispatcher now runs the wizard first (which edits the draft
embed), then sends the response as either
`InteractionCallback.Modal(modalProperties)` (when the new step
needs text input) or `InteractionCallback.DeferredMessage()`
(otherwise). NetCord locks the response type after the first
`SendResponseAsync` call, so the fix is NOT to call
`DeferredMessage` upfront.
- **Modal handler's free-text mapping is a hack.** Modal steps like
`SystemFreeText`, `DurationFreeText`, `PoolSystemDurationFreeText`
are mapped to the canonical wizard step (`System`, `Duration`,
`PoolSystemDuration`) in `MapModalStepToWizardStep`. This works
because the wizard's `ApplyText` dispatches on the canonical step
name, but a future refactor of `ApplyText` to know about the
free-text step names would break this. The clean fix is to add
dedicated "free text" steps to `WizardStepNames`.
- **Resume:continue is a re-render, not a true resume.** The wizard
has no special resume case; clicking "▶️ Продолжить" just re-emits
the current step's embed. This is fine for the user (the embed is
identical to the last one they saw before the click), but the
underlying state isn't really "continued" — if the wizard's cleanup
service expired the draft between the slash command and the
click, the user gets a re-render of an empty step.
- **One-draft-per-owner invariant.** The wizard's "one active draft
per owner" rule means a single Discord user can't run two wizard
sessions in parallel. Acceptable for now, but the wizard's state
machine doesn't enforce this — only the dispatcher does, via
`GetActiveAsync("Discord", ownerId)`.
## Final commit history on `feat/issue-112-wizard-refactor`
- `8f0f2ef` — Task 1: platform-neutral wizard refactor (core + Shared types)
- `b81d865` — Task 2: Discord adapter scaffolding (messenger, step, submitter, command)
- `f095209` — Task 2: interaction module + modal popup fix
- `7cfb196` — Task 2: select/modal parser off-by-one + real club lookup
PR link: https://git.codeanddice.ru/Toutsu/GmRelayBot/pulls/new/feat/issue-112-wizard-refactor
@@ -56,8 +56,18 @@ Aspire обеспечивает:
- Service discovery и передачу connection strings.
- OpenTelemetry (traces, metrics, logs) из коробки.
- Aspire Dashboard для мониторинга.
- **Три сервиса:** Bot (Telegram long polling + Discord Gateway), Web, PostgreSQL.
### 5. Telegram.Bot 22.x + Long Polling
### 5. Discord Gateway + NetCord
Discord-интеграция реализована через NetCord Gateway (не DSharpPlus) из-за:
- Нативной совместимости с .NET 10 и минимального размера зависимостей.
- Gateway events маршрутизируются в те же vertical slice handlers, что и Telegram updates.
- Slash-команды регистрируются через NetCord `ApplicationCommandService`.
Ephemeral-ответы (кнопки Join/Leave/RSVP) используют `InteractionMessageProperties` с `Flags = MessageFlags.Ephemeral`.
### 6. Telegram.Bot 22.x + Long Polling
- Long Polling — единственный вариант для Pi за NAT.
- Telegram.Bot поддерживает `System.Text.Json` source generators для AOT.
@@ -0,0 +1,69 @@
# ADR 002: Platform-Neutral Batch Rendering
## Status
**Accepted** — implemented in v1.10.0 (PR #42).
## Context
`SessionBatchRenderer` жил в `GmRelay.Shared` и напрямую зависел от `Telegram.Bot` (`InlineKeyboardMarkup`, `ParseMode.Html`). Это создавало проблемы:
1. **Shared не был platform-neutral.** Любой платформенный проект (Discord, Slack, WebSocket) тащил Telegram-зависимость.
2. **Дублирование логики.** `GmRelay.Web` использовал тот же рендерер через прямую зависимость от `Shared`, но Web — это не Telegram-клиент.
3. **Невозможно написать unit-тесты без Telegram-объектов.** Smoke-тесты создавали InlineKeyboardMarkup даже для проверки чисто доменной логики.
## Decision
Разделить рендеринг на две стадии:
1. **View Builder (platform-neutral)** — собирает view model из доменных DTO.
2. **Platform Renderer (platform-specific)** — превращает view model в платформенное представление.
```
Domain DTOs
SessionBatchViewBuilder (Shared)
SessionBatchViewModel (platform-neutral)
├──► TelegramSessionBatchRenderer ──► HTML + InlineKeyboardMarkup
└──► DiscordSessionBatchRenderer ──► Discord embeds + buttons
```
### Изменённые компоненты
| Компонент | Было | Стало |
|---|---|---|
| `SessionBatchRenderer` | `GmRelay.Shared.Rendering` | Удалён |
| `SessionBatchViewBuilder` | — | `GmRelay.Shared.Rendering` |
| `SessionBatchViewModel` | — | `GmRelay.Shared.Rendering` |
| `TelegramSessionBatchRenderer` | — | `GmRelay.Bot` + `GmRelay.Web` |
| `DiscordSessionBatchRenderer` | — | `GmRelay.DiscordBot.Rendering` |
| `BatchMessageEditor` | `GmRelay.Shared.Rendering` | `GmRelay.Bot` + `GmRelay.Web` |
## Consequences
### Positive
- `GmRelay.Shared` больше не зависит от `Telegram.Bot`. Чистый platform-agnostic проект.
- Discord renderer lives in `GmRelay.DiscordBot`, so NetCord stays out of `Shared`.
- Unit-тесты ViewBuilder не создают `InlineKeyboardMarkup`.
- Логика подсчёта игроков, сортировки сессий и генерации действий — в одном месте (ViewBuilder).
### Negative
- **Временное дублирование.** `TelegramSessionBatchRenderer` и `BatchMessageEditor` скопированы в `Bot` и `Web`. Планируется вынести в `GmRelay.Shared.Telegram` при появлении третьего Telegram-потребителя.
- **Дополнительная стадия.** Теперь два вызова вместо одного: `Build` + `Render`. Этоtrade-off за чистоту абстракции.
## Related
- Issue #22 — этот рефакторинг.
- Issue #26 — Discord Bot MVP (потребитель новой архитектуры).
- Issue #30 — Discord reschedule voting использует `IPlatformMessenger`.
- Issue #31 — scheduler notifications и reschedule deadline updates через `IPlatformMessenger`.
- Issue #32 — compose wiring для Discord bot (healthcheck :8082).
- Issue #33 — регрессионные тесты platform rendering (Telegram + Discord).
- ADR 001 — vertical slice, native AOT, Aspire (`docs/adr/0001-use-vertical-slice-native-aot-and-aspire.md`).
@@ -0,0 +1,57 @@
# ADR 003: Discord Integration Architecture
## Status
**Accepted** — implemented in v2.6.0 (PR #87, issue #30).
## Context
После Telegram-бота требовалась поддержка Discord для кросс-платформенных групп. Нужно было выбрать:
1. Библиотеку для Discord API (NetCord vs DSharpPlus vs Discord.NET).
2. Модель runtime (отдельный процесс vs тот же Worker).
3. Способ обработки интеракций (Gateway events vs HTTP interactions).
## Decision
### 1. NetCord (не DSharpPlus, не Discord.NET)
- **NetCord** — лёгкий, AOT-compatible, minimal dependencies.
- **DSharpPlus** — слишком тяжёлый, много зависимостей, reflection-heavy.
- **Discord.NET** — несовместим с Native AOT (heavy reflection, dynamic IL).
### 2. Gateway Events внутри GmRelay.Bot
- Discord Gateway worker живёт **внутри** `GmRelay.Bot` (тот же Worker Service), а не как отдельный проект.
- Это упрощает DI, shared DB connection, shared `IPlatformMessenger`.
- Для масштабирования можно вынести в отдельный контейнер позже.
### 3. Slash-команды через NetCord ApplicationCommandService
- Регистрация глобальных slash-команд (`/newsession`, `/listsessions`) через `ApplicationCommandService`.
- Команды мапятся на vertical slice handlers через `DiscordSessionInteractionModule`.
### 4. Ephemeral Replies
- Все кнопки (Join/Leave/RSVP) отвечают ephemeral (`MessageFlags.Ephemeral`).
- Schedule message редактируется через `DiscordPlatformMessenger` (реализация `IPlatformMessenger`).
## Consequences
### Positive
- Один бинарник для Telegram + Discord.
- Shared DI, shared DB pool, shared domain logic.
- Native AOT совместимость.
### Negative
- Gateway connection требует persistent WebSocket — при разрыве происходит reconnect.
- Discord rate limits агрессивнее Telegram — нужен backoff.
## Related
- Issue #30 — reschedule voting (кнопки + дедлайн).
- Issue #31 — scheduler notifications через `IPlatformMessenger`.
- Issue #32 — compose wiring + healthcheck.
- ADR 001 — Vertical Slice, Native AOT, Aspire.
- ADR 002 — Platform-Neutral Rendering.
+146 -39
View File
@@ -1,4 +1,4 @@
# GM-Relay C4 Model
# GM-Relay - C4 Model
## Level 1: System Context
@@ -6,19 +6,27 @@
C4Context
title GM-Relay System Context
Person(gm, "Game Master", "Создаёт сессии, управляет расписанием игр")
Person(player, "Player", "Подтверждает участие через inline-кнопки")
Person(gm, "Game Master", "Creates sessions and manages schedules")
Person(player, "Player", "Joins, leaves, confirms, and receives reminders")
Person(visitor, "Public visitor", "Views published club schedules, sessions, GM profiles, and completed-adventure portfolio pages without private player data")
System(gmrelay, "GM-Relay Bot", "Telegram Worker Service на Raspberry Pi. Управляет подтверждениями, рассылает напоминания и ссылки.")
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, public club/session/GM profile/portfolio pages, and shared scheduling logic")
System_Ext(telegram, "Telegram Bot API", "Long Polling. Сообщения, inline keyboards, callback queries.")
SystemDb_Ext(postgres, "PostgreSQL", "Сессии, игроки, RSVP-статусы")
System_Ext(telegram, "Telegram Bot API", "Commands, inline keyboards, callback queries, Mini App entry points")
System_Ext(discord, "Discord Gateway and REST API", "Slash commands, button interactions, message edits, ephemeral replies")
SystemDb_Ext(postgres, "PostgreSQL", "Sessions, players, participants, groups, platform identities, master_profiles, portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews, cover_storage_keys")
Rel(gm, telegram, "Команды бота (/newsession)")
Rel(player, telegram, "Нажимает кнопки (✅ Буду / ❌ Не смогу)")
Rel(telegram, gmrelay, "Updates (Long Polling)")
Rel(gm, telegram, "Creates and manages sessions")
Rel(gm, discord, "Uses /newsession and /listsessions")
Rel(player, telegram, "Uses inline buttons")
Rel(player, discord, "Uses Join/Leave and RSVP buttons")
Rel(player, gmrelay, "Submits moderated reviews for completed-adventure portfolios")
Rel(visitor, gmrelay, "Views public club, session, GM profile, and portfolio pages")
Rel(telegram, gmrelay, "Updates via long polling")
Rel(discord, gmrelay, "Gateway events and component interactions")
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
Rel(gmrelay, postgres, "SQL (Npgsql + Dapper)")
Rel(gmrelay, discord, "Send/edit schedule, RSVP, reminder, and reschedule messages")
Rel(gmrelay, postgres, "SQL via Npgsql and Dapper")
```
## Level 2: Container
@@ -29,50 +37,149 @@ C4Container
Person(gm, "Game Master")
Person(player, "Player")
Person(visitor, "Public visitor")
System_Boundary(pi, "Raspberry Pi 5") {
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Long polling, обработка команд и callback queries, планировщик")
ContainerDb(db, "PostgreSQL 16", "Database", "sessions, players, session_participants, game_groups")
System_Boundary(runtime, "Docker Compose / Aspire runtime") {
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Telegram long polling, commands, callback routing, reminders")
Container(discordBot, "Discord Gateway Worker", "Внутри GmRelay.Bot", "NetCord Gateway, slash commands, scheduler notifications, button interactions, healthcheck :8082")
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, public club/session/GM profile/portfolio pages, portfolio review submission and moderation, editing and stats")
Container(shared, "GmRelay.Shared", ".NET library", "Shared domain models, rendering, scheduler, and platform-neutral handlers")
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, publication settings, master_profiles, portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews, platform identities")
}
System_Ext(telegram, "Telegram Bot API")
System_Ext(discord, "Discord Gateway and REST API")
SystemDb_Ext(covers, "Portfolio covers volume", "Persistent file store for portfolio cover uploads (LocalPortfolioCoverStorage; S3-compatible replacement boundary)")
Rel(gm, telegram, "Commands")
Rel(player, telegram, "Callback Queries")
Rel(telegram, bot, "GetUpdates (Long Polling)")
Rel(gm, discord, "Slash commands")
Rel(player, telegram, "Callback queries")
Rel(player, discord, "Button interactions")
Rel(player, web, "Submits moderated reviews on completed-adventure portfolio pages")
Rel(visitor, web, "Read-only public schedule, sanitized GM profile, and completed-adventure portfolio pages")
Rel(telegram, bot, "GetUpdates")
Rel(discord, discordBot, "Gateway events")
Rel(bot, telegram, "Bot API calls")
Rel(discordBot, discord, "REST send/edit/reply calls")
Rel(bot, shared, "Uses shared renderers and join/leave handlers")
Rel(discordBot, shared, "Uses shared renderers, scheduler, and platform-neutral handlers")
Rel(web, shared, "Uses shared domain and rendering models")
Rel(bot, db, "Npgsql + Dapper.AOT")
Rel(discordBot, db, "Npgsql + Dapper")
Rel(web, db, "Npgsql + Dapper")
Rel(web, covers, "Saves, reads, and deletes cover files via IPortfolioCoverStorage")
```
## Level 3: Component (GmRelay.Bot)
## Level 3: Component - Session Interactions
```mermaid
C4Component
title GmRelay.Bot Components
title Platform-Neutral Session Interactions
Container_Boundary(bot, "GmRelay.Bot") {
Component(polling, "TelegramBotService", "BackgroundService", "Long polling loop, получает Updates")
Component(router, "UpdateRouter", "C#", "Маршрутизирует Update → Handler по типу")
Component(scheduler, "SessionSchedulerService", "BackgroundService", "PeriodicTimer(60s): T-24ч и T-5мин триггеры")
Component(migrator, "DbMigrator", "DbUp", "SQL миграции при старте")
Component(confirm, "SendConfirmationHandler", "Feature", "Отправляет inline keyboard за 24ч")
Component(rsvp, "HandleRsvpHandler", "Feature", "Обрабатывает ✅/❌, проверяет all-confirmed")
Component(link, "SendJoinLinkHandler", "Feature", "Отправляет join link за 5 мин")
Container_Boundary(shared, "GmRelay.Shared") {
Component(join, "JoinSessionHandler", "Feature handler", "Adds players as Active or Waitlisted with session row locking")
Component(leave, "LeaveSessionHandler", "Feature handler", "Removes players and promotes the first waitlisted player when capacity allows")
Component(rsvp, "HandleRsvpHandler", "Feature handler", "Updates RSVP state and emits platform-neutral RSVP outcomes")
Component(scheduler, "SessionSchedulerService", "Background service", "Triggers confirmation, reminder, and join-link notifications per platform")
Component(updateLock, "ScheduleMessageUpdateLock", "In-memory keyed lock", "Serializes DB changes and schedule message edits per platform message")
Component(renderer, "SessionBatchViewBuilder", "Renderer model builder", "Builds platform-neutral schedule views and actions")
}
System_Ext(telegram, "Telegram Bot API")
ContainerDb(db, "PostgreSQL")
Component(healthCheck, "DiscordHealthCheckHostedService", ":8082", "Healthcheck для Docker Compose")
Rel(polling, router, "Update")
Rel(router, rsvp, "CallbackQuery rsvp:*")
Rel(scheduler, confirm, "T-24h trigger")
Rel(scheduler, link, "T-5min trigger")
Rel(confirm, telegram, "SendMessage + InlineKeyboard")
Rel(rsvp, telegram, "EditMessage + AnswerCallback")
Rel(link, telegram, "SendMessage + user mentions")
Rel(confirm, db, "SELECT/UPDATE sessions")
Rel(rsvp, db, "UPDATE participants, SELECT counts")
Rel(link, db, "SELECT confirmed players")
Rel(migrator, db, "DDL migrations")
Container_Boundary(discordBot, "GmRelay.DiscordBot") {
Component(discordModule, "DiscordSessionInteractionModule", "NetCord component module", "Maps join_session/leave_session/rsvp buttons to neutral commands")
Component(discordMessenger, "DiscordPlatformMessenger", "IPlatformMessenger", "Sends and edits Discord schedule, RSVP, reminder, join-link, and reschedule messages")
}
Container_Boundary(bot, "GmRelay.Bot") {
Component(updateRouter, "UpdateRouter", "Telegram adapter", "Maps callback queries to neutral commands")
Component(telegramMessenger, "TelegramPlatformMessenger", "IPlatformMessenger", "Sends and edits Telegram schedule, RSVP, reminder, join-link, and reschedule messages")
}
ContainerDb(db, "PostgreSQL")
System_Ext(telegram, "Telegram Bot API")
System_Ext(discord, "Discord Gateway and REST API")
Rel(discord, discordModule, "Button interaction")
Rel(discordModule, join, "JoinSessionCommand")
Rel(discordModule, leave, "LeaveSessionCommand")
Rel(discordModule, rsvp, "HandleRsvpCommand")
Rel(discordModule, discord, "Deferred ephemeral reply, then modify response")
Rel(updateRouter, join, "JoinSessionCommand")
Rel(updateRouter, leave, "LeaveSessionCommand")
Rel(updateRouter, rsvp, "HandleRsvpCommand")
Rel(join, updateLock, "Acquire by PlatformMessageRef")
Rel(leave, updateLock, "Acquire by PlatformMessageRef")
Rel(join, db, "SELECT FOR UPDATE, INSERT participant")
Rel(leave, db, "SELECT FOR UPDATE, DELETE/promote participant")
Rel(rsvp, db, "Update RSVP and load notification recipients")
Rel(scheduler, db, "Load due session triggers")
Rel(join, renderer, "Build updated schedule view")
Rel(leave, renderer, "Build updated schedule view")
Rel(join, discordMessenger, "Update Discord schedule when command is Discord")
Rel(leave, discordMessenger, "Update Discord schedule when command is Discord")
Rel(join, telegramMessenger, "Update Telegram schedule when command is Telegram")
Rel(leave, telegramMessenger, "Update Telegram schedule when command is Telegram")
Rel(rsvp, discordMessenger, "Update Discord confirmation and outcomes")
Rel(rsvp, telegramMessenger, "Update Telegram confirmation and outcomes")
Rel(scheduler, discordMessenger, "Send Discord scheduler notifications")
Rel(scheduler, telegramMessenger, "Send Telegram scheduler notifications")
Rel(discordMessenger, discord, "REST send/edit/DM + ephemeral text")
Rel(telegramMessenger, telegram, "SendMessage/EditMessage + AnswerCallbackQuery")
Rel(healthCheck, discord, "HTTP /health")
```
## Level 3: Component - Completed-Adventure Portfolios
The portfolio subsystem lets GMs curate completed adventures from past sessions, publish a public detail page, and collect moderated player reviews. The cover files live in a persistent volume via the `IPortfolioCoverStorage` boundary; the public schema and contracts are isolated inside `GmRelay.Web.Services.Portfolio` so a future S3-compatible storage adapter can replace `LocalPortfolioCoverStorage` without touching the data layer.
```mermaid
C4Component
title Completed-Adventure Portfolio Subsystem
Person(gm, "Game Master", "Curates completed adventures and moderates reviews")
Person(player, "Player", "Submits one moderated review per completed adventure")
Person(visitor, "Public visitor", "Reads public portfolio pages and approved reviews")
Container_Boundary(web, "GmRelay.Web") {
Component(authorized, "AuthorizedPortfolioService", "Feature service", "Manager authorization, review submission authorization, identity resolution, cover cleanup orchestration")
Component(store, "PortfolioService", "Feature service", "Portfolio CRUD, public reads, review submission, moderation; SQL via Dapper.AOT and advisory locks")
Component(covers, "IPortfolioCoverStorage", "Storage boundary", "LocalPortfolioCoverStorage saves/reads/deletes cover files; S3-compatible replacement boundary")
Component(pages, "PublicPortfolio.razor", "Blazor page", "Renders /portfolio/{slug} and review form for participants")
Component(editor, "PortfolioEditor.razor", "Blazor page", "Renders /group/{id}/portfolio editor, cover upload, and review moderation queue")
}
ContainerDb(db, "PostgreSQL")
ContainerDb_Ext(coversVolume, "portfolio_covers volume", "Persistent file store for cover uploads")
Rel(gm, editor, "Creates, edits, publishes, moderates reviews")
Rel(player, pages, "Submits review")
Rel(visitor, pages, "Reads public portfolio and approved reviews")
Rel(pages, authorized, "GetReviewSubmissionStateForCurrentUserAsync, SubmitReviewForCurrentUserAsync")
Rel(pages, store, "GetPublicPortfolioGamesForClubAsync, GetPublicPortfolioGamesForMasterAsync, GetPublicPortfolioGameBySlugAsync")
Rel(editor, authorized, "GetPortfolioGamesForCurrentUserAsync, CreateDraftForCurrentUserAsync, UpdateDraftForCurrentUserAsync, ReplaceCoverForCurrentUserAsync, SetPublicationForCurrentUserAsync, ModerateReviewForCurrentUserAsync")
Rel(authorized, store, "All manager-gated reads/writes; identity and group authorization")
Rel(authorized, covers, "Save, read, delete cover files")
Rel(authorized, sessionStore, "ISessionStore.IsGroupManagerAsync / ResolveEffectivePlayerIdAsync")
Rel(store, db, "INSERT/UPDATE/SELECT on portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews")
Rel(covers, coversVolume, "Filesystem reads/writes")
Rel(editor, covers, "Cover file path via IPortfolioCoverStorage.GetPublicPath")
Rel(pages, covers, "Cover file path via IPortfolioCoverStorage.GetPublicPath")
```
### Portfolio tables (PostgreSQL)
| Table | Purpose |
|---|---|
| `portfolio_games` | Adventure header: `title`, `description`, `system`, `format`, `public_slug`, `cover_storage_key`, `completed_at`, `is_public`, `published_at` |
| `portfolio_game_sessions` | Many-to-many link from `portfolio_games` to past `sessions` used to assemble the adventure |
| `portfolio_game_masters` | Many-to-many link from `portfolio_games` to `players` who are managers of the source group |
| `portfolio_game_reviews` | Player reviews: `author_player_id`, `author_display_name`, `body`, `publication_consent_at`, `moderation_status` (`Pending` / `Approved` / `Rejected` / `Hidden`), `moderated_by_player_id`, `moderated_at` |
### Cover storage boundary
- `IPortfolioCoverStorage` is registered as a DI singleton in `GmRelay.Web`.
- The current implementation `LocalPortfolioCoverStorage` writes under `PortfolioCovers:StoragePath` (default `/app/portfolio-covers`) and is mounted as the Docker volume `portfolio_covers` (configurable via `PORTFOLIO_COVERS_VOLUME_NAME` in `.env`).
- Static files are served by the web container at `/portfolio-covers/{storageKey}` with `Cache-Control: public, max-age=31536000, immutable`.
- Replacing the local filesystem with S3-compatible object storage is a contract-only change: implement `IPortfolioCoverStorage` with the same `SaveAsync` / `GetPublicPath` / `DeleteIfExistsAsync` surface and swap the DI registration in `PortfolioCoverStorageExtensions.AddPortfolioCoverStorage`.
+54
View File
@@ -0,0 +1,54 @@
"""Detailed code review plan for the Discord wizard feature branch.
Read this file FIRST. It has the full review scope. The original prompt
in the spawn was truncated due to Windows CLI limits; this file is the
canonical spec.
"""
# Branch to review
BRANCH = "feat/issue-112-wizard-refactor"
BASE = "origin/main"
# Files of interest
SHARED = "src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/*"
BOT_WIZARD = "src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/*"
BOT_CREATE = "src/GmRelay.Bot/Features/Sessions/CreateSession/*"
MIGRATION = "src/GmRelay.Bot/Migrations/V032__add_wizard_drafts_platform.sql"
DISCORD = "src/GmRelay.DiscordBot/Features/Sessions/Wizard/*"
PROG_CS = "src/GmRelay.DiscordBot/Program.cs"
TESTS_WIZ = "tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/*"
TESTS_SMOKE = "tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs"
DELIVERABLE = "deliverable.md"
REVIEW_FOCUS = [
"Architecture: Shared/Bot/DiscordBot separation; no Telegram.Bot in Shared; no NetCord in Shared; single state machine source.",
"Security: owner/co-GM checks everywhere; NRE on null Context.User; SQL injection; connection strings with passwords.",
"Correctness: AOT-safety (no reflection, no dynamic); off-by-one in customId parsers; CancellationToken/Services.",
"Style: naming consistent; Async/await by convention; logging at right levels.",
"Tests: smoke tests are string-matching — where would real tests be useful?",
"Migration safety: V032 DEFAULT value, will it fail on existing rows?",
"Documentation: deliverable.md updated, open questions listed?",
]
OUTPUT_FORMAT = """\
## VERDICT: APPROVE / REQUEST_CHANGES / COMMENT
## Critical findings
(file:line — what's wrong — how to fix)
## Important findings
(file:line — what's wrong)
## Nits
(quick observations)
## Summary
(1-2 sentences)
"""
COMMANDS_HINT = """\
git fetch origin
git diff origin/main..feat/issue-112-wizard-refactor --stat
git diff origin/main..feat/issue-112-wizard-refactor
dotnet build && dotnet test
"""
+362
View File
@@ -0,0 +1,362 @@
# Code review — feat/issue-112-wizard-refactor (issue #112)
**Reviewer:** Verifier (mvs_86868b01387b492aae27ce6f77aca4cb)
**Branch:** `feat/issue-112-wizard-refactor` (base `origin/main`)
**Commits reviewed:** `8f0f2ef`, `b81d865`, `f095209`, `7cfb196`, `c4a77d3`
**Build:**`dotnet build GM-Relay.slnx` — 0 warnings, 0 errors
**Tests:** 580 passed / 2 skipped / 1 failed. 1 failure is the pre-existing
`DiscordProjectStructureTests.Version_ShouldBeSynchronizedForDiscordFeatureRelease`
(uncommitted release work in working tree, not part of this branch).
## VERDICT: REQUEST_CHANGES
The branch is **NOT shippable in its current state.** Every choice button
and every "Другое…" button in the wizard is silently broken at runtime
due to a wire-format mismatch between the renderer and the dispatcher.
A user who clicks "D&D 5e", "Pathfinder 2e", "Waitlist вкл", "Опубликовать",
or any "Другое… ✏️" button will see "⚠️ Неизвестная кнопка" instead of
the wizard advancing. The 12 source-level smoke tests don't catch this
because they only check string presence in source code, not the actual
button-click → dispatch flow.
The architecture is otherwise sound: no Telegram.Bot/NetCord leak into
Shared, single state-machine source, all DI wired, AOT-safe, parameterized
SQL, owner/co-GM permission check with null-safety, SecretRedactor on the
connection string. The fix is small and surgical.
---
## Critical findings
### C-1. Choice-button custom-id is missing the `choice:` segment — wizard is unusable end-to-end
**Files:**
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:79-80`
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs:174-226`
**What's wrong.** The dispatcher's button handler matches `parts[1]`
against `"choice"`, `"back"`, `"cancel"`, `"create"`, `"resume"`, and
falls through to "default → Неизвестная кнопка" for anything else. The
dispatcher's own documentation and the deliverable's wire-format table
both agree the canonical choice-button format is
`wizard:btn:choice:<step>:<value>`. But `ButtonCustomId` emits
`$"wizard:btn:{step}:{value}"` — **the literal `choice:` segment is
missing**. So clicking "D&D 5e" on the System step produces
`wizard:btn:System:Dnd5e`, which NetCord strips the `[ComponentInteraction("wizard")]`
prefix from, arriving at the dispatcher as `args = "btn:System:Dnd5e"`
`parts = ["btn", "System", "Dnd5e"]``parts[1] = "System"` → default
branch → "⚠️ Неизвестная кнопка".
The same bug hits:
- `RenderType` — "Одну игру" / "Пул игр" buttons (emits
`wizard:btn:Type:single`, `wizard:btn:Type:pool`)
- `RenderSystem` — D&D/Pathfinder/CoC/GURPS/Fate ("wizard:btn:System:Dnd5e" etc.)
**and** the "⏭ Пропустить" button (emits `wizard:btn:System:_skip`)
- `RenderDuration` — "3 часа" / "4 часа" / "5 часов" / "6 часов" and
"⏭ Пропустить"
- `RenderCapacity` / `RenderPoolSlotCapacity` — "Waitlist вкл" / "Без waitlist"
(emits `wizard:btn:Capacity:waitlist:on` etc.)
- `RenderPublish` — "Опубликовать" / "Только в чате"
- `RenderPoolAddSlots` — "Добавить слот" / "Готово, к превью"
- `RenderPickClub` — back/cancel still work (parts[1] = "back"/"cancel")
Only back, cancel, create, resume, and the "Другое… ✏️" → modal buttons
are unaffected by *this specific* bug (see C-2 for modal buttons).
The smoke test `Dispatcher_ShouldParseAllWizardActionKinds`
(`tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs:85-97`)
checks that the strings `"choice"`, `"back"`, `"cancel"`, `"create"`,
`"resume"` appear in `DiscordWizardInteractionModule.cs`. It doesn't
check the renderer's output, so the bug is invisible to the test suite.
The same file's comment at line 69 documents the *expected* format as
`"btn:choice:Type:single"` — which would be the correct fix.
**How to fix.** Change `ButtonCustomId` in `DiscordWizardStep.cs`:
```csharp
public static string ButtonCustomId(string step, string value) =>
$"wizard:btn:choice:{step}:{value}";
```
This brings the renderer into alignment with the dispatcher's switch
case `"choice"` (line 209) and with the deliverable's table at line 83.
Re-verify with a manual click-through of every button on every step, or
add a parser-side test (see I-3 below).
### C-2. "Другое… ✏️" modal trigger buttons route to "default" instead of opening a modal
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:190, 206, 314`
**Read against:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs:207-226`
**What's wrong.** The renderer emits modal triggers as
`wizard:btn:modal:SystemFreeText` (the "Другое… ✏️" button on the System
step), `wizard:btn:modal:DurationFreeText` (Duration step), and
`wizard:btn:modal:PoolSystemDurationFreeText` (PoolSystemDuration step).
The dispatcher's button switch handles `"choice"`, `"back"`, `"cancel"`
but not `"modal"`. The user's click on "Другое…" hits the default branch
and returns "⚠️ Неизвестная кнопка" — no modal pops up, the wizard
doesn't advance. The open question in `deliverable.md:125-132` ("Modal
handler's free-text mapping is a hack") implicitly assumes these buttons
*work* in production, so the design intent is clear but the implementation
didn't deliver it.
**How to fix.** Add a `"modal"` case in the dispatcher's switch (between
`"create"` and the existing branches, mirroring the "create" / "resume"
special-case pattern):
```csharp
if (parts[1] == "modal" && parts.Length >= 3)
{
var modal = DiscordWizardStep.BuildModal(parts[2], draft.ChatId);
if (modal is not null)
{
await context.Interaction.SendResponseAsync(InteractionCallback.Modal(modal));
}
else
{
await AckWithErrorAsync(context.Interaction, "Модал недоступен");
}
return;
}
```
This bypasses the wizard's state machine entirely (the user's intent is
"open a modal for free-text input", not "advance the wizard"). When the
user submits the modal, `HandleModalAsync` will run, which already knows
how to map `SystemFreeText``WizardStepNames.System` (line 453-462).
Add a click-through test for at least one of the three steps.
### C-3. `ex.Message` from `CreateSessionHandler` is shipped to the user's Discord
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs:104`
**What's wrong.** On submit failure the submitter edits the draft
message with `$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}."`
and ships it to the user-visible draft embed. The exception originates
in `CreateSessionHandler` which talks to PostgreSQL via Dapper. Postgres
exception messages routinely include the constraint name, the conflicting
key value, and sometimes the full SQL text. Even the connection-string
DSN could leak if an `NpgsqlException` wraps a connection failure. A
malicious user who can submit many sessions can probe DB schema and
state by reading the error strings.
**How to fix.** Log the full `ex` to the server-side log (already done
on line 86) but show the user a generic error:
```csharp
await EditDraftMessageAsync(
draft,
$"💥 Ошибка при создании сессии. Попытка {payload.RetryCount}/{MaxRetries}. "
+ "Попробуйте повторить или обратитесь к администратору.",
RetryCancelActions(),
ct);
```
If you want to preserve a per-error recovery hint (e.g. "Duplicate
title — pick a different name"), map known exception types to localized
strings; never embed the raw `ex.Message`.
---
## Important findings
### I-1. `Owner`/`CoGm` permission lookup runs on every wizard invocation, no cache
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs:85-87`
The slash command issues an `await DiscordPermissionLookup.LoadManagerUserIdsAsync(...)`
on every `/newsession-wizard` invocation. This is a 3-table join that
scales linearly with the number of clubs the user manages. With a 24-hour
draft lifetime and a single draft per owner, the same query repeats
frequently. Not critical for the v3.8.0 release, but a 30-second in-memory
cache would cut DB load noticeably during heavy wizard use. Same query
shape lives in `DiscordNewSessionHandler` already (per the file comment),
so a shared cache would benefit both.
### I-2. `_skip` sentinel bypasses the wizard's own validation
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:191, 207`
**Read against:** `src/GmRelay.Shared/Features/Sessions\CreateSession\Wizard\GameCreationWizard.cs:287-290`
`GameCreationWizard.ApplySystemChoice` matches `"_skip"` and accepts it
without further checks. The renderer emits `wizard:btn:System:_skip`.
This is correct *if* the choice-button wire format gets fixed (C-1); the
"`_skip`" string is hard-coded in the wizard and the renderer uses the
same constant. But there's no central constant — both files have their
own copies of the magic string. A future refactor that renames the
sentinel in one place will silently break the other. Suggest
`public const string SkipSentinel = "_skip"` on a shared class.
### I-3. Smoke tests are string-matching only — no behaviour coverage of the adapter
**File:** `tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs`
All 12 smoke tests in this file are `Assert.Contains` against the
source text. They would all pass against a file full of `// choice`
comments and dead code. The test class header at line 8-17 acknowledges
this ("smoke gate"), and the broader Wizard test suite
(`tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/`) does
exercise the platform-neutral state machine — but the *adapter* (the
mapping from `ButtonInteractionContext``_wizard.HandleInteractionAsync`)
has zero behavioural coverage. C-1 and C-2 both bypass the entire
smoke-test surface.
Minimum bar to add: a parser-roundtrip test that takes the renderer's
output for each `RenderX()` step and feeds it through the dispatcher's
button handler to verify it doesn't fall into the default branch. Even
a hand-rolled `ButtonInteractionContext` fake (or a helper that mimics
the dispatcher's `args.Split(':', 4)` parser) would catch both bugs.
Cost: ~50 lines; payoff: catches the entire class of "renderer and
dispatcher disagree on the wire format" regressions.
### I-4. `AddComponentInteractions<TInteraction, TContext>` is called for Modal but the renderer relies on `Label → TextInput` layout
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:436-444`
**Read against:** `DiscordWizardInteractionModule.cs:440-451`
`BuildModal` always wraps a single `TextInput` in a `Label` (Discord's
`IModalComponentProperties` API requires labels). The dispatcher reads
`Components[0]` and assumes it is a `Label` (line 446). This is
consistent *today*, but if a future step needs two inputs in one
modal, the extraction logic needs to walk all components, not just
`[0]`. Document the constraint on the dispatcher's
`ExtractModalText` method ("current contract: exactly one Label,
one TextInput") and the renderer's `BuildModal` ("emits one
Label+TextInput, no exceptions").
### I-5. The 3-retry counter is bound to the in-memory `WizardPayload`, not the DB row
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs:86-89`
`payload.RetryCount += 1; SavePayload(draft, payload);` is called *before*
the `if (payload.RetryCount >= MaxRetries)` check. The counter is
serialized into `draft.PayloadJson` and re-loaded on the next click, so
the bound is correct across bot restarts. However, the in-memory
`draft` object is shared with the dispatcher's `_wizard` after
`HandleInteractionAsync` returns, and a future refactor that pulls the
payload from the DB instead of the in-memory copy could see a stale
count. Document the invariant: "RetryCount is read from the in-memory
payload after this line; do not re-load from DB before the comparison."
### I-6. `BuildResumeRow` re-uses the same customId suffix scheme as the in-wizard buttons
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs:193-209`
`BuildResumeRow` emits three buttons:
- "▶️ Продолжить" → `wizard:btn:resume:continue` ✓ (works)
- "🔄 Заново" → `wizard:btn:resume:restart` ✓ (works)
- "❌ Отмена" → `wizard:btn:cancel:1` (uses `DiscordWizardStep.ButtonCustomId("cancel", "1")`)
The cancel button relies on the dispatcher's `parts[1] == "cancel"`
match. With C-1 fixed, this still works because cancel is in the
switch, not the new "choice" path. But the `ButtonCustomId` signature
will change semantics after C-1: it will become
`$"wizard:btn:choice:{step}:{value}"`. `BuildResumeRow` passing
`"cancel"` as the step will then produce `wizard:btn:choice:cancel:1`,
which the dispatcher's switch will not match (no `"choice"` in the
parts). Fix C-1 must update `BuildResumeRow` to emit
`wizard:btn:cancel:1` directly (not via `ButtonCustomId`).
---
## Nits
- `DiscordWizardInteractionModule.cs:308, 357` — the select and modal
handlers split args with `Split(':', 2)` (max 2 parts), while the
button handler uses `Split(':', 4)` (max 4 parts). Inconsistent. The
net effect is correct (select has 2 segments, modal has 2, button has
24), but a comment explaining the max-count rationale would help.
- `DiscordWizardStep.cs:74``throw new InvalidOperationException` on
unknown step is fine for now, but a future maintainer adding a step
to `WizardStepNames` will get a runtime exception. A `switch` exhaustiveness
check (e.g. a private static assert in tests) would catch this at
build time.
- `DiscordPermissionLookup.cs:28-30``g.platform = 'Discord'` is
hard-coded in the SQL. A `g.platform = @Platform` parameter would
mirror the dispatcher's parameterized style and make the helper
reusable for any future platform. Not blocking.
- `DiscordWizardCommand.cs:72-81` — fetching the guild via REST inside
the slash command costs an extra round-trip. The
`resolvedPermissions` from the interaction already includes
`Administrator`; only the "guild owner" case needs the REST call.
Consider short-circuiting when `(resolvedPermissions & Administrator)
!= 0`.
- `DiscordWizardMessenger.cs:188-192` — hard-coded
`new Color(0x5865F2)` (Discord blurple). Extracting to a const
`WizardEmbedColor` would let the Web/Telegram versions use the same
brand color if they ever render wizards.
---
## Migration V032 sanity check
**File:** `src/GmRelay.Bot/Migrations/V032__wizard_drafts_platform.sql`
- Line 8-9 `ADD COLUMN platform TEXT NOT NULL DEFAULT 'Telegram'`
— DEFAULT literal makes this O(1) on PostgreSQL ≥ 11. Safe on
existing rows.
- Lines 13-30 — `ALTER COLUMN ... TYPE TEXT USING ...::TEXT` on the
`chat_id`, `message_thread_id`, `draft_message_id`, and renamed
`owner_id` columns. All conversions are lossless
(BIGINT → decimal-string, INT → decimal-string). Safe.
- Line 26-27 `RENAME COLUMN owner_telegram_id TO owner_id` — the
rename happens mid-migration. Any DML hitting the table
concurrently that uses the old name will fail. For a bot that
processes both Telegram and Discord traffic, this is a brief
exclusivity lock. Consider splitting into two migrations
(rename + new index, then type change) so each lock is shorter.
Not blocking for v3.8.0, but document the brief lock window.
No DEFAULT-cascade issue, no NOT NULL on existing-row failure. The
deliverable's "Will this fail on existing rows?" question gets a
"no, but plan a maintenance window" answer.
---
## Architecture sanity (re-confirmed)
- `src/GmRelay.Shared/` — only references to Telegram.Bot/NetCord are
in doc comments warning the developer not to add them. csproj has
no Telegram.Bot or NetCord package references. `GameCreationWizard`,
`IWizardMessenger`, `WizardCallbackData`, `WizardStepLimits`,
`WizardStepNames`, `WizardPayload`, `WizardDraft` are all in exactly
one place (Shared). ✓
- `src/GmRelay.DiscordBot/Program.cs:87-102` — all 7 wizard services
registered as singleton. All 3 `AddComponentInteractions<...>`
calls present (Button, StringMenu, Modal). All 4 module/dispatcher
classes (`WizardInteractionDispatcher`, `DiscordWizardButtonModule`,
`DiscordWizardStringMenuModule`, `DiscordWizardModalModule`)
registered. ✓
- AOT-safety: no `System.Reflection`, no `dynamic`, no
`Activator.CreateInstance`, no `Type.GetType` in the new Discord
or Shared code. ✓
- `DiscordPermissionLookup.cs:23-31` and
`DiscordWizardMessenger.cs:154-165` and the inline
`WizardClubLookup` in `DiscordWizardInteractionModule.cs:508-519`
all use parameterized queries (`@GuildId`, `@Platform`,
`@ExternalId`, `@OwnerId`). No SQL string interpolation. ✓
- `Program.cs:54``SecretRedactor.RedactConnectionString` on the
startup log. ✓
- `DiscordWizardCommand.cs:51-94` — DM invocations rejected
(`GuildId` null check), channel null-checked, member type-checked
via `as GuildInteractionUser`, owner/admin/DB-manager permission
check via `DiscordPermissionChecker.CanManageSchedule`. No NRE
on `Context.User`. ✓
---
## Summary
Strong foundation: the platform-neutral refactor is well-executed, the
state machine has solid test coverage, the Discord adapter's DI graph
is clean, and security primitives (parameterized SQL, permission check,
secret redaction) are in place. But the Discord adapter's runtime path
is untested, and a single oversight in the renderer's button custom-id
format (missing the `choice:` segment) breaks every choice button in
the wizard at click time. The "Другое… ✏️" modal triggers are also
unrouted in the dispatcher, leaving the free-text input path
unreachable. The 3-attempt finalize loop works but leaks `ex.Message`
to the user. After fixing C-1 / C-2 / C-3, adding I-3 (behavioural
test of the adapter), and re-running the manual click-through checklist
(System → Duration → DateTime → Capacity → Visibility → Publish →
Confirm for Single; full pool flow), this branch is ready to merge.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,234 @@
# Game Catalog and One-Shot Showcase — Design Spec
> Issue #39: feat: добавить каталог игр и витрину ваншотов
---
## Goal
Build a public `/showcase` page that aggregates published sessions from all clubs into a filterable catalog. Users can browse games by system, format, date, and availability. GM controls whether direct registration from the catalog is allowed. The catalog respects existing seat limits and waitlist logic.
---
## Architecture
Extend the existing public-pages infrastructure (V026) with new session metadata fields, a cross-group query layer in `ISessionStore`, and new Razor pages in `GmRelay.Web`. Bot flows (Telegram + Discord) are updated to collect the new fields during session creation. Fuzzy matching on game system names is performed client-side in the bot UI.
---
## Tech Stack
- .NET 10, Blazor Server, Dapper.AOT, Npgsql
- Existing: `PublicLayout`, `ISessionStore`, `SessionService`, `SessionCapacityRules`
- New: `GameSystem` enum, `ShowcaseFilter` record, `ShowcaseSessionDto`
---
## Data Model
### New Fields on `sessions` (Migration V027)
| Column | Type | Constraints | Description |
|---|---|---|---|
| `is_one_shot` | `BOOLEAN` | `NOT NULL DEFAULT false` | One-shot or campaign |
| `system` | `VARCHAR(50)` | nullable | Game system name (enum value or custom) |
| `description` | `TEXT` | nullable | Short description for card |
| `cover_image_url` | `TEXT` | nullable | Cover image URL |
| `duration_minutes` | `INTEGER` | nullable | Duration in minutes |
| `format` | `VARCHAR(20)` | `CHECK (format IN ('Online','Offline','Hybrid'))`, nullable | Session format |
| `allow_direct_registration` | `BOOLEAN` | `NOT NULL DEFAULT false` | Allow direct registration from showcase |
### `GameSystem` Enum
```csharp
public enum GameSystem
{
Dnd5e, Pathfinder2e, CallOfCthulhu7e, Shadowdark,
OldSchoolEssentials, Dragonbane, BladesInTheDark,
Daggerheart, CyberpunkRed, Mothership, AlienRpg,
WarhammerFantasy, VampireMasquerade5e, StarWarsFfg,
Genesys, SavageWorlds, GURPS, Fate, DungeonWorld,
Ironsworn, Other
}
```
Stored as `VARCHAR(50)` in DB (not native enum) to allow future extension without migration.
### DTOs
```csharp
public sealed record ShowcaseSessionDto(
Guid Id,
Guid GroupId,
string GroupName,
string? GroupSlug,
string Title,
DateTime ScheduledAt,
string Status,
string? System,
bool IsOneShot,
string? Format,
int? DurationMinutes,
string? CoverImageUrl,
int? MaxPlayers,
int ActivePlayerCount,
int WaitlistedPlayerCount,
bool AllowDirectRegistration);
public sealed record ShowcaseFilter(
DateFilter Date = DateFilter.All,
SeatFilter Seats = SeatFilter.Any,
GameSystem? System = null,
bool? IsOneShot = null,
string? Format = null);
public enum DateFilter { Today, Tomorrow, ThisWeek, All }
public enum SeatFilter { Available, Waitlist, Any }
```
---
## UI Design
### `/showcase` — Catalog Page
**Layout:**
- Hero with title "Каталог игр"
- Sticky filter bar (horizontal on desktop, collapsible on mobile)
- Responsive grid of session cards (1 col mobile, 2 col tablet, 3 col desktop)
- Pagination (page + pageSize = 12)
**Filters:**
- Date: "Сегодня" | "Завтра" | "На неделю" | "Все"
- Seats: "Есть места" | "Waitlist" | "Любое"
- System: dropdown with all `GameSystem` values
- Type: "Ваншот" | "Кампания" | "Любое"
- Format: "Онлайн" | "Офлайн" | "Гибрид" | "Любое"
**Card Design:**
- Cover image (fallback: colored placeholder with initials)
- Title
- System badge
- Date + time (MSK)
- Duration (e.g. "3 часа")
- Format badge
- Seats indicator: "5/6 мест" | "Waitlist (3)" | "Мест нет"
- Club name (link to `/club/{slug}`)
- Buttons: "Подробнее" → `/s/{id}`, "Записаться" (if `AllowDirectRegistration`)
### `/s/{id}` — Public Session Detail (Updated)
New fields added to existing page:
- Cover image (full-width hero)
- System badge
- Description block
- Duration + format
- GM contact (always visible: Telegram username or Discord tag)
- If `allow_direct_registration`:
- "Записаться" button → Telegram Mini App deeplink or Discord OAuth
- Direct registration into `session_participants` via `SessionCapacityRules`
---
## Backend
### ISessionStore Methods
```csharp
Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(
ShowcaseFilter filter, int page, int pageSize);
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
Task<bool> RegisterFromShowcaseAsync(Guid sessionId, PlatformUser user);
```
`GetShowcaseSessionsAsync` query:
- Cross-group (all clubs with `public_schedule_enabled = true`)
- Only `is_public = true` sessions
- `scheduled_at > now() - interval '4 hours'`
- `status <> 'Cancelled'`
- Apply filters in SQL WHERE clause
- Order by `scheduled_at ASC`
- Offset/limit pagination
`RegisterFromShowcaseAsync`:
- Check `allow_direct_registration = true`
- Load session with `FOR UPDATE`
- Count active + waitlisted participants
- Use `SessionCapacityRules.DecideJoinStatus`
- Insert participant with appropriate `registration_status`
- Return true on success, false if full and no waitlist allowed
---
## Bot Integration
### Telegram Bot
During `CreateSessionCommand` flow, after title/link/time input:
1. "Выберите систему:" inline keyboard with `GameSystem` values + "Другое"
2. If text input instead of button: fuzzy match against display names (Levenshtein/Contains/StartsWith)
3. "Описание игры (краткое):" — text input, optional (skip button)
4. "Формат:" inline keyboard — "Онлайн" | "Офлайн" | "Гибрид"
5. "Продолжительность (в часах):" — int input, optional
6. "Обложка (URL или пропустить):" — text input, optional
During `/publish` flow:
- "Разрешить прямую запись из каталога?" — yes/no toggle (default: no)
### Discord Bot
Same flow adapted for Discord interactions:
- Slash command options or button menus for system/format
- Modal input for description, duration, cover URL
- Fuzzy matching on free-text system input
---
## Migration V027
```sql
ALTER TABLE sessions
ADD COLUMN is_one_shot BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN system VARCHAR(50),
ADD COLUMN description TEXT,
ADD COLUMN cover_image_url TEXT,
ADD COLUMN duration_minutes INTEGER,
ADD COLUMN format VARCHAR(20) CHECK (format IN ('Online','Offline','Hybrid')),
ADD COLUMN allow_direct_registration BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX ix_sessions_showcase
ON sessions (scheduled_at, system, is_one_shot, format)
WHERE is_public = true AND status <> 'Cancelled';
```
---
## Testing Strategy
1. **Unit tests:** `SessionCapacityRules` with showcase registration scenarios
2. **Integration tests:** `GetShowcaseSessionsAsync` with each filter combination
3. **UI tests:** `Showcase.razor` rendering with/without cover images, filters applied
4. **Bot tests:** Fuzzy matching algorithm for `GameSystem` resolution
---
## Version Bump
Issue label: `type:feature`**minor bump**
Current: `3.3.0` → Next: `3.4.0`
Files to sync:
- `Directory.Build.props`
- `compose.yaml` (bot, discord, web image tags)
- `.gitea/workflows/deploy.yml` (`VERSION` env)
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
---
## Acceptance Criteria (from Issue #39)
- [ ] User can find a published game without accessing a private dashboard
- [ ] Registration does not bypass existing seat/waitlist limits
- [ ] Owner/co-GM controls what appears in the showcase via `is_public` + `allow_direct_registration`
- [ ] Filters work: date, seats, system, type, format
- [ ] GM contact is always visible on public session detail
- [ ] Direct registration respects `SessionCapacityRules`
@@ -0,0 +1,424 @@
# Completed Game Portfolio - Design Spec
> Issue #108: feat: добавить портфолио прошедших игр в витрину мастера
---
## Goal
Add a public portfolio of completed tabletop adventures. A club owner or co-GM can group one or more completed sessions into an adventure card, publish it in selected GM profiles, optionally show it on a public club page, upload a cover image, and moderate player reviews. The existing `/showcase` catalog remains focused on recruitment for upcoming games.
---
## Product Decisions
- A portfolio item is an independent adventure entity, not a flag on one session.
- One adventure can reference multiple completed sessions from the same club.
- Reviews are submitted by authenticated players, not entered manually by a GM.
- A player can review an adventure after being actively registered as a non-GM participant for at least one linked completed session. Waitlisted players are not eligible.
- Each player can submit one review per adventure.
- A review is public only after the player explicitly consents to publication and a club owner or co-GM approves it.
- Public reviews show a display-name snapshot captured at submission time. They never expose platform IDs or account links.
- Adventure visibility in a public GM profile does not depend on club-page visibility.
- The public club page shows its portfolio block only when that club page is enabled.
- Club owners and co-GMs create, edit, publish, and moderate portfolio items. They select one or more GMs whose public profiles display the adventure.
- Creation is available from the club page and through a quick action from a completed session.
- Every published adventure has a dedicated public page at `/portfolio/{slug}`.
- Cover images are uploaded to application-managed storage. The first implementation uses a persistent Docker volume behind a replaceable storage interface so an S3-compatible implementation can be added later without changing pages or database tables.
---
## Architecture
Add a bounded portfolio vertical slice to `GmRelay.Web` and a schema migration in `GmRelay.Bot`. The portfolio tables reference the existing `game_groups`, `players`, and `sessions` tables but do not change the recruitment catalog query or its future-session filters.
Keep portfolio persistence separate from the already large scheduling store. `IPortfolioStore` and `PortfolioService` own portfolio reads, writes, and review submission. `AuthorizedPortfolioService` wraps protected management operations and reuses `ISessionStore.IsGroupManagerAsync` plus the existing current-user identity model for owner/co-GM authorization. Public Razor pages inject `IPortfolioStore` directly for sanitized reads.
Cover storage is isolated behind `IPortfolioCoverStorage`. Pages and services work with generated storage keys and public paths rather than physical file locations. The local implementation stores files in a persistent mounted directory and serves them through a dedicated request path. A future S3 implementation can generate equivalent public paths or signed delivery URLs while preserving the same service contract and database fields.
---
## Data Model
### Migration V029
Create `src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql`.
### `portfolio_games`
| Column | Type | Constraints | Description |
|---|---|---|---|
| `id` | `UUID` | primary key, generated | Adventure identifier |
| `group_id` | `UUID` | not null, FK to `game_groups(id)` with cascade delete | Owning club |
| `public_slug` | `VARCHAR(160)` | unique case-insensitive when non-null | Public route segment |
| `title` | `VARCHAR(255)` | not null | Adventure title |
| `description` | `TEXT` | nullable for drafts | Public description |
| `cover_storage_key` | `TEXT` | nullable for drafts | Storage-provider-neutral cover key |
| `system` | `VARCHAR(50)` | nullable | Game system |
| `format` | `VARCHAR(20)` | nullable, checked against `Online`, `Offline`, `Hybrid` | Play format |
| `completed_at` | `TIMESTAMPTZ` | not null | Portfolio ordering date |
| `is_public` | `BOOLEAN` | not null, default false | Public visibility |
| `published_at` | `TIMESTAMPTZ` | nullable | First publication timestamp |
| `created_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp |
| `updated_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp |
Constraints and indexes:
```sql
CHECK (NOT is_public OR (
public_slug IS NOT NULL
AND description IS NOT NULL
AND cover_storage_key IS NOT NULL
AND published_at IS NOT NULL
))
```
- Unique index on `lower(public_slug)` when `public_slug IS NOT NULL`.
- Index on `(group_id, completed_at DESC)`.
- Partial public index on `(completed_at DESC)` where `is_public = true`.
Application validation additionally requires at least one linked session, every linked session to be completed with `scheduled_at < now()`, and at least one linked GM before publishing because those requirements span child tables. Publishing locks the parent card, validates both required link sets, then sets `is_public = true` and `published_at = COALESCE(published_at, now())` so `published_at` remains the first-publication timestamp. Link replacement locks the parent card and unpublishes it before replacing required links.
Immediate statement triggers acquire one transaction-level PostgreSQL advisory lock, `pg_advisory_xact_lock(20260530, 108)`, before any invariant-affecting rows are changed: publication transitions and deletes, required-link edits, session deletes and scheduled-date changes, and parent deletes that can cascade into required links. Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public, a session link is inserted, deleted, moved, or repointed, or a required master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets or with any linked session where `scheduled_at >= now()`. Portfolio and schedule mutations are low volume, so this intentionally global lock establishes one advisory-lock then row-lock protocol, prevents write-skew under the application default `READ COMMITTED` isolation level, and avoids multi-card, card/advisory, and session/advisory deadlocks. PostgreSQL keeps a stale snapshot after waiting under `REPEATABLE READ` or `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those levels; callers must use `READ COMMITTED` for portfolio mutations.
A deferred `sessions.scheduled_at` trigger atomically unpublishes linked public cards when a completed session is finally rescheduled into the future, preserving the first `published_at`. Because deferred row triggers retain their event-time `NEW`, the trigger re-reads the final `sessions.scheduled_at` before acting. It rejects final-future reschedules outside `READ COMMITTED` with `0A000`, because the unpublish pass requires fresh statement snapshots. Under `READ COMMITTED`, it takes row locks for all cards linked to any final-future session in `portfolio_games.id` order, including committed drafts. It then re-acquires the publication advisory lock and unpublishes matching public cards in a guarded update with a fresh statement snapshot. Including drafts prevents a concurrent draft-to-public publication from validating against the pre-reschedule session snapshot and committing afterward. Session mutation paths use advisory-lock then `sessions` then linked `portfolio_games`; normal session-deletion handlers explicitly acquire the mutation lock, lock the target session row, unpublish linked cards in the same transaction, and only then delete the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless.
### `portfolio_game_sessions`
| Column | Type | Constraints | Description |
|---|---|---|---|
| `portfolio_game_id` | `UUID` | not null, FK to `portfolio_games(id)` with cascade delete | Adventure |
| `session_id` | `UUID` | not null, unique, FK to `sessions(id)` with cascade delete | Linked completed session |
Primary key: `(portfolio_game_id, session_id)`.
The application accepts only sessions from the adventure's club with `scheduled_at < now()` and rejects cross-club links. The deferred database guard enforces the completed-session condition for every linked session before a public card can commit. A session belongs to at most one portfolio adventure.
### `portfolio_game_masters`
| Column | Type | Constraints | Description |
|---|---|---|---|
| `portfolio_game_id` | `UUID` | not null, FK to `portfolio_games(id)` with cascade delete | Adventure |
| `player_id` | `UUID` | not null, FK to `players(id)` with cascade delete | Displayed GM |
Primary key: `(portfolio_game_id, player_id)`.
Add an index on `(player_id, portfolio_game_id)` for public GM profile reads.
### `portfolio_game_reviews`
| Column | Type | Constraints | Description |
|---|---|---|---|
| `id` | `UUID` | primary key, generated | Review identifier |
| `portfolio_game_id` | `UUID` | not null, FK to `portfolio_games(id)` with cascade delete | Adventure |
| `author_player_id` | `UUID` | not null, FK to `players(id)` with cascade delete | Private author reference |
| `author_display_name` | `VARCHAR(255)` | not null | Public snapshot |
| `body` | `TEXT` | not null | Review text |
| `publication_consent_at` | `TIMESTAMPTZ` | not null | Player consent timestamp |
| `moderation_status` | `VARCHAR(20)` | not null, default `Pending`, checked | Moderation state |
| `moderated_by_player_id` | `UUID` | nullable, FK to `players(id)` with set null on delete | Private moderator reference |
| `moderated_at` | `TIMESTAMPTZ` | nullable | Moderation timestamp |
| `created_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp |
| `updated_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp |
Constraints and indexes:
```sql
CHECK (moderation_status IN ('Pending', 'Approved', 'Rejected', 'Hidden'))
UNIQUE (portfolio_game_id, author_player_id)
```
- Author lookup index `ix_portfolio_game_reviews_author` on `(author_player_id)`.
- Partial moderator lookup index `ix_portfolio_game_reviews_moderator` on `(moderated_by_player_id)` where `moderated_by_player_id IS NOT NULL`.
- Partial public index on `(portfolio_game_id, created_at DESC)` where `moderation_status = 'Approved'` and `publication_consent_at IS NOT NULL`.
- Partial moderation index on `(portfolio_game_id, created_at DESC)` where `moderation_status = 'Pending'`.
---
## Cover Storage
### Contract
Add a small storage abstraction:
```csharp
public interface IPortfolioCoverStorage
{
Task<PortfolioCoverUploadResult> SaveAsync(
Stream content,
string contentType,
CancellationToken cancellationToken = default);
Task DeleteIfExistsAsync(
string storageKey,
CancellationToken cancellationToken = default);
string GetPublicPath(string storageKey);
}
```
`PortfolioCoverUploadResult` carries the generated storage key and normalized content type.
### Local Implementation
- Store covers below a configured `PortfolioCovers:StoragePath`.
- Mount that path from a dedicated Docker volume, `portfolio_covers`.
- Serve files through a dedicated `/portfolio-covers/{storageKey}` route.
- Generate random names. Never use the uploaded filename as the storage key.
- Accept `image/jpeg`, `image/png`, and `image/webp`.
- Limit uploads to 5 MiB.
- Validate file signatures server-side before writing the final file.
- Write to a temporary file, validate, then atomically move into place.
- On successful replacement, delete the old file.
- On database failure after upload, delete the newly uploaded file.
- Deleting an adventure deletes its current cover after successful database deletion.
The storage key remains provider-neutral. A future S3-compatible implementation can replace the local service registration and use the same stored key.
---
## Service Contracts
Add sanitized DTOs to `IPortfolioStore`. Public DTOs must not expose player IDs, group IDs, session IDs, platform identifiers, moderator IDs, physical storage paths, or join links.
Representative contracts:
```csharp
public sealed record PublicPortfolioGame(
string Slug,
string Title,
string Description,
string CoverPath,
string? System,
string? Format,
DateTime CompletedAt,
string? ClubName,
string? ClubSlug,
IReadOnlyList<PublicPortfolioMaster> Masters,
IReadOnlyList<PublicPortfolioReview> Reviews);
public sealed record PublicPortfolioMaster(string Slug, string DisplayName);
public sealed record PublicPortfolioReview(
string AuthorDisplayName,
string Body,
DateTime CreatedAt);
```
Protected DTOs may carry IDs needed for editing and moderation.
### Public Reads
- Load one public adventure by slug for `/portfolio/{slug}`.
- Load public adventures for a public GM profile regardless of club-page visibility.
- Load public adventures for a public club page only when the club page is enabled.
- Return only reviews with explicit consent and `Approved` moderation state.
### Protected Management
Through `AuthorizedPortfolioService`:
- Load draft and published adventure cards for a managed club.
- Load eligible completed sessions for a managed club.
- Create a draft, optionally preselecting one completed session from the quick action.
- Update title, slug, description, system, format, linked sessions, and displayed GMs.
- Upload and replace the cover.
- Publish or unpublish a card.
- Load pending and historical reviews for moderation.
- Approve, reject, or hide a review.
All management operations require the current user to be an owner or co-GM of the owning club.
### Review Submission
An authenticated user can submit a review from `/portfolio/{slug}` only when:
- The adventure is public.
- The user explicitly checks publication consent.
- The user is registered in `session_participants` as a non-GM participant with `registration_status = 'Active'` for at least one linked session.
- The linked session is in the past.
- The user has not submitted a review for this adventure before.
The created review starts in `Pending`. The public page does not display it until moderation changes the status to `Approved`.
---
## User Interface
### Protected Club Page
Extend `GroupDetails.razor` with a completed-adventures section:
- List draft and published portfolio cards.
- Show title, publication state, linked-session count, displayed-GM count, and review moderation count.
- Provide a create action, edit links, and a link to the club's completed-session list.
### Completed Session Quick Action
Add a protected `/group/{groupId}/completed` page that lists past sessions for a managed club. Extend that page and session history with an "Добавить в портфолио" action for a completed session that is not already linked. The action opens the adventure editor with that session preselected.
### Adventure Editor
Add a protected editor page:
- Title and public slug.
- Description.
- System and format.
- Multi-select of completed sessions from the same club.
- Multi-select of displayed GMs.
- Cover upload and replacement.
- Draft save and publish/unpublish actions.
- Review moderation list with approve, reject, and hide actions.
The editor surfaces validation errors without publishing partial data.
### Public GM Profile
Extend `/gm/{slug}` with a "Проведённые приключения" portfolio section. Cards show cover, title, completion date, system, format, and a link to `/portfolio/{slug}`. This list is independent of club-page visibility.
### Public Club Page
Extend `/club/{slug}` with the same compact cards when the public club page is enabled.
### Public Adventure Page
Add `/portfolio/{slug}`:
- Cover hero.
- Title, description, completion date, system, and format.
- Optional public club link.
- Public links to selected GM profiles.
- Approved reviews with display-name snapshots.
- For an eligible authenticated player without an existing review: review form with text area and required publication-consent checkbox.
- For an authenticated ineligible player or a player who already submitted: a short non-sensitive status message.
- For an anonymous visitor: a sign-in prompt instead of the form.
---
## Privacy And Security
- Public DTOs and rendered HTML never expose platform identifiers, player IDs, moderator IDs, linked session IDs, join links, or physical storage paths.
- Cover upload validation uses content signatures, not only the browser-provided MIME type or filename.
- Random storage keys prevent filename guessing and path traversal.
- Review text is rendered as encoded text through normal Razor rendering.
- Authorization is checked in the service layer for every management operation.
- Eligibility is checked in the database-backed service when submitting a review; hiding the form is not treated as authorization.
- The `/showcase` query keeps its current future-session condition and does not include completed adventures.
---
## Docker And Configuration
Add:
```yaml
services:
discord:
depends_on:
bot:
condition: service_healthy
web:
depends_on:
bot:
condition: service_healthy
environment:
- "PortfolioCovers__StoragePath=/app/portfolio-covers"
volumes:
- portfolio_covers:/app/portfolio-covers
volumes:
portfolio_covers:
name: ${PORTFOLIO_COVERS_VOLUME_NAME:-gmrelay_portfolio_covers}
```
Development configuration uses a local directory under the application content root or an explicitly configured path.
The Web Docker image creates `/app/portfolio-covers` and assigns it to `$APP_UID` before switching to the non-root runtime user.
The Telegram bot runs `DbMigrator` synchronously before its health endpoint becomes healthy. Docker Compose therefore starts Discord and Web only after the bot is healthy, using it as the schema-migration gate without duplicating migration ownership. The Aspire AppHost mirrors this readiness gate with database resource name `gmrelaydb`, matching application `ConnectionStrings:gmrelaydb`; it explicitly exposes the bot project resource's non-proxied port `8081` endpoint, attaches `.WithHttpHealthCheck("/health", endpointName: "health")`, and makes its `discord` and `web` project resources wait for both PostgreSQL and the healthy `bot` resource.
---
## Documentation
Update:
- `README.md` with public portfolio capability and local cover-storage configuration.
- `docs/c4-system-context.md` with the portfolio slice and persistent cover volume.
---
## Testing Strategy
Follow TDD for production changes.
### Schema And Contracts
- Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, immediate statement-level mutation locks, completed-session validator, deferred future-reschedule unpublish trigger, advisory-lock then session-row deletion locks, and the AppHost HTTP health gate.
- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit mutation-lock then session-lock then unpublish then session deletion, delete/reschedule mutation-gate ordering in both first-lock orders, rejection of publication when any linked session is future, automatic unpublish with preserved `published_at` after future reschedule, `past -> future -> past` final-state handling, required-link insertion and final-future reschedule mutation locks before rows, opposing-order batch future reschedules serialized before session rows, existing-link and new-link draft publication/reschedule races, both bounded publish/delete commit orders, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes including both draft-delete versus publish commit orders and stale-snapshot final-future reschedules, and parent/card cascade deletion.
- Public DTO reflection/source tests assert that private identifiers and physical storage paths are absent.
- Existing showcase tests continue to assert the future-session catalog boundary.
### Authorization And Eligibility
- Owner and co-GM can manage a club adventure.
- A manager of another club cannot manage it.
- Only registered players from linked past sessions can submit.
- A registered player can submit only once.
- Consent is required.
- A new review is pending and not public.
- Only approved reviews are returned publicly.
### Cover Storage
- Accept valid JPEG, PNG, and WebP signatures.
- Reject unsupported types, mismatched signatures, oversized files, and unsafe names.
- Replacement deletes the old file only after the new file is stored.
- Cleanup removes a newly uploaded file when persistence fails.
### UI Source Contracts
- Protected club and session-history pages expose management entry points.
- Public GM and club pages render compact portfolio sections.
- The public adventure page renders approved reviews and the conditional review form.
- CSS defines responsive portfolio cards, cover hero, editor layout, and review states.
### Regression
- Run the full test suite.
- Run `dotnet build`.
- Run `dotnet format --verify-no-changes`.
- Visually inspect the protected editor and public portfolio pages in the browser.
---
## Version Bump
Issue label: `type:feature` -> minor bump.
Current: `3.5.1` -> Next: `3.6.0`.
Synchronize:
- `Directory.Build.props`
- `compose.yaml` (`bot`, `discord`, and `web` image tags)
- `.gitea/workflows/deploy.yml` (`VERSION`)
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
---
## Acceptance Criteria Mapping
- [ ] A club owner or co-GM can publish a completed adventure with uploaded cover and description.
- [ ] A portfolio adventure can group one or more completed sessions from the same club.
- [ ] A public portfolio adventure automatically becomes private if any linked completed session is rescheduled into the future, preserving its first-publication timestamp.
- [ ] Selected public GM profiles show portfolio cards independently of club-page visibility.
- [ ] A public club page shows portfolio cards when enabled.
- [ ] `/portfolio/{slug}` shows cover, description, metadata, selected GMs, and approved player reviews.
- [ ] A registered participant of a linked completed session can submit one review with explicit publication consent.
- [ ] Reviews remain non-public until owner/co-GM moderation approves them.
- [ ] Public DTOs and HTML do not expose private identifiers.
- [ ] Uploaded covers survive container replacement through a persistent Docker volume.
- [ ] Storage is isolated behind a replaceable interface for a later S3-compatible implementation.
- [ ] The existing `/showcase` catalog remains focused on upcoming recruitment games.
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# GM-Relay PostgreSQL Backup Restore Script
# Usage: ./scripts/restore.sh [backup_file]
# If no file is provided, uses the most recent backup.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# Check required env
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
if [ -f "${PROJECT_ROOT}/.env" ]; then
# shellcheck source=/dev/null
set -a
. "${PROJECT_ROOT}/.env"
set +a
fi
fi
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
echo "ERROR: POSTGRES_PASSWORD is not set. Please set it in your environment or .env file."
exit 1
fi
BACKUP_DIR="${PROJECT_ROOT}/backups"
# Determine backup file
if [ $# -ge 1 ]; then
BACKUP_FILE="$1"
else
BACKUP_FILE=$(find "${BACKUP_DIR}" -name 'gmrelay_db_*.sql.gz' -type f -printf '%T+ %p\n' 2>/dev/null | sort -r | head -n1 | cut -d' ' -f2-)
if [ -z "${BACKUP_FILE}" ]; then
echo "ERROR: No backup files found in ${BACKUP_DIR}."
exit 1
fi
fi
if [ ! -f "${BACKUP_FILE}" ]; then
echo "ERROR: Backup file not found: ${BACKUP_FILE}"
exit 1
fi
echo "=================================================="
echo " GM-Relay PostgreSQL Restore"
echo "=================================================="
echo ""
echo "Backup file: ${BACKUP_FILE}"
echo "Database: gmrelay_db"
echo "User: gmrelay"
echo ""
read -p "This will OVERWRITE the current database. Are you sure? [y/N] " CONFIRM
if [[ ! "${CONFIRM}" =~ ^[Yy]$ ]]; then
echo "Restore cancelled."
exit 0
fi
echo ""
echo "Restoring database from ${BACKUP_FILE}..."
# Restore using docker compose exec to leverage the running postgres container
COMPOSE_ARGS="-f ${PROJECT_ROOT}/compose.yaml"
docker compose ${COMPOSE_ARGS} exec -T -e PGPASSWORD="${POSTGRES_PASSWORD}" db psql \
-U gmrelay \
-d gmrelay_db \
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" 2>/dev/null || true
gunzip -c "${BACKUP_FILE}" | docker compose ${COMPOSE_ARGS} exec -T -e PGPASSWORD="${POSTGRES_PASSWORD}" db psql \
-U gmrelay \
-d gmrelay_db
echo ""
echo "=================================================="
echo " Restore completed successfully!"
echo "=================================================="
@@ -2,6 +2,7 @@
<ItemGroup>
<ProjectReference Include="..\GmRelay.Bot\GmRelay.Bot.csproj" />
<ProjectReference Include="..\GmRelay.DiscordBot\GmRelay.DiscordBot.csproj" />
<ProjectReference Include="..\GmRelay.Web\GmRelay.Web.csproj" />
</ItemGroup>
+12 -4
View File
@@ -2,14 +2,22 @@ var builder = DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("postgres")
.WithPgAdmin()
.AddDatabase("gmrelay-db");
.AddDatabase("gmrelaydb");
builder.AddProject<Projects.GmRelay_Bot>("bot")
var bot = builder.AddProject<Projects.GmRelay_Bot>("bot")
.WithReference(postgres)
.WaitFor(postgres);
.WaitFor(postgres)
.WithHttpEndpoint(port: 8081, targetPort: 8081, name: "health", isProxied: false)
.WithHttpHealthCheck("/health", endpointName: "health");
builder.AddProject<Projects.GmRelay_DiscordBot>("discord")
.WithReference(postgres)
.WaitFor(postgres)
.WaitFor(bot);
builder.AddProject<Projects.GmRelay_Web>("web")
.WithReference(postgres)
.WaitFor(postgres);
.WaitFor(postgres)
.WaitFor(bot);
builder.Build().Run();
+687
View File
@@ -0,0 +1,687 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Aspire.Dashboard.Sdk.win-x64": {
"type": "Direct",
"requested": "[13.2.1, )",
"resolved": "13.2.1",
"contentHash": "KLB9rXwY8kg2taWwxsJFoK0cAuupSZurcv1zTyYMqLyNuwvYYjs65Yz3g/cgh22QlUfOT3tOh+Jzk5MdJhy5+w=="
},
"Aspire.Hosting.AppHost": {
"type": "Direct",
"requested": "[13.2.1, )",
"resolved": "13.2.1",
"contentHash": "4B/eoZPwOobxpMpvYnqe/EcXabjPhZJhfxlHXv5gdKd16duoWbHnvvAZJsVI3WUpakCwmsCiTrT4sNGfW8H+IQ==",
"dependencies": {
"AspNetCore.HealthChecks.Uris": "9.0.0",
"Aspire.Hosting": "13.2.1",
"Google.Protobuf": "3.33.5",
"Grpc.AspNetCore": "2.76.0",
"Grpc.Net.ClientFactory": "2.76.0",
"Grpc.Tools": "2.78.0",
"Humanizer.Core": "2.14.1",
"JsonPatch.Net": "3.3.0",
"KubernetesClient": "18.0.13",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics.HealthChecks": "10.0.5",
"Microsoft.Extensions.FileSystemGlobbing": "10.0.5",
"Microsoft.Extensions.Hosting": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Http": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5",
"ModelContextProtocol": "1.0.0",
"Newtonsoft.Json": "13.0.4",
"Polly.Core": "8.6.5",
"Semver": "3.0.0",
"StreamJsonRpc": "2.22.23",
"System.IO.Hashing": "10.0.3"
}
},
"Aspire.Hosting.Orchestration.win-x64": {
"type": "Direct",
"requested": "[13.2.1, )",
"resolved": "13.2.1",
"contentHash": "39lRUH4WuCsBaYB7fZH1/r81SSJIXrA8WphBlAdP1QT95+1sKQHzXJuXU4nzKpBLv4oZmjcWzvA+FDMGZbWmkw=="
},
"Aspire.Hosting.PostgreSQL": {
"type": "Direct",
"requested": "[13.2.1, )",
"resolved": "13.2.1",
"contentHash": "7F/nmeplR9cYE/B/E1haRjnkoBRQ/voMXpnK/SNJoXSFs4Vb/g00CDDvI/xfH3SAV7Xq8ekWa9ZbX56JuQ+YiA==",
"dependencies": {
"AspNetCore.HealthChecks.NpgSql": "9.0.0",
"AspNetCore.HealthChecks.Uris": "9.0.0",
"Aspire.Hosting": "13.2.1",
"Google.Protobuf": "3.33.5",
"Grpc.AspNetCore": "2.76.0",
"Grpc.Net.ClientFactory": "2.76.0",
"Grpc.Tools": "2.78.0",
"Humanizer.Core": "2.14.1",
"JsonPatch.Net": "3.3.0",
"KubernetesClient": "18.0.13",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.25",
"Microsoft.Extensions.FileSystemGlobbing": "10.0.5",
"Microsoft.Extensions.Hosting": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Http": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5",
"ModelContextProtocol": "1.0.0",
"Newtonsoft.Json": "13.0.4",
"Polly.Core": "8.6.5",
"Semver": "3.0.0",
"StreamJsonRpc": "2.22.23",
"System.IO.Hashing": "10.0.3"
}
},
"SecurityCodeScan.VS2019": {
"type": "Direct",
"requested": "[5.6.7, )",
"resolved": "5.6.7",
"contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ=="
},
"Aspire.Hosting": {
"type": "Transitive",
"resolved": "13.2.1",
"contentHash": "GY/T5iK2F4K3Sk60VUeVnTX1MhCjSaX48+qPUjA/rI1x1ONHevHzFj+Gc3fNlGEaZGY8L87hSxwGrV+Bjd5EJw==",
"dependencies": {
"AspNetCore.HealthChecks.Uris": "9.0.0",
"Google.Protobuf": "3.33.5",
"Grpc.AspNetCore": "2.76.0",
"Grpc.Net.ClientFactory": "2.76.0",
"Grpc.Tools": "2.78.0",
"Humanizer.Core": "2.14.1",
"JsonPatch.Net": "3.3.0",
"KubernetesClient": "18.0.13",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.25",
"Microsoft.Extensions.FileSystemGlobbing": "10.0.5",
"Microsoft.Extensions.Hosting": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Http": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5",
"ModelContextProtocol": "1.0.0",
"Newtonsoft.Json": "13.0.4",
"Polly.Core": "8.6.5",
"Semver": "3.0.0",
"StreamJsonRpc": "2.22.23",
"System.IO.Hashing": "10.0.3"
}
},
"AspNetCore.HealthChecks.NpgSql": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "npc58/AD5zuVxERdhCl2Kb7WnL37mwX42SJcXIwvmEig0/dugOLg3SIwtfvvh3TnvTwR/sk5LYNkkPaBdks61A==",
"dependencies": {
"Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11",
"Npgsql": "8.0.3"
}
},
"AspNetCore.HealthChecks.Uris": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "XYdNlA437KeF8p9qOpZFyNqAN+c0FXt/JjTvzH/Qans0q0O3pPE8KPnn39ucQQjR/Roum1vLTP3kXiUs8VHyuA==",
"dependencies": {
"Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11",
"Microsoft.Extensions.Http": "8.0.0"
}
},
"Fractions": {
"type": "Transitive",
"resolved": "7.3.0",
"contentHash": "2bETFWLBc8b7Ut2SVi+bxhGVwiSpknHYGBh2PADyGWONLkTxT7bKyDRhF8ao+XUv90tq8Fl7GTPxSI5bacIRJw=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.33.5",
"contentHash": "XEzLpCTosZb5I6eGSPn7rAES0VfkJkn3Cqydh0W39POdZwkdhPhOmAROTFJF9g0ardst4ulNXRm/q/iXwNu+Qw=="
},
"Grpc.AspNetCore": {
"type": "Transitive",
"resolved": "2.76.0",
"contentHash": "LyXMmpN2Ba0TE35SOLSKbGqIYtJuhc1UgiaGfoW1X8KJERV70QI5KGW+ckEY7MrXoFWN/uWo4B70siVhbDmCgQ==",
"dependencies": {
"Google.Protobuf": "3.31.1",
"Grpc.AspNetCore.Server.ClientFactory": "2.76.0",
"Grpc.Tools": "2.76.0"
}
},
"Grpc.AspNetCore.Server": {
"type": "Transitive",
"resolved": "2.76.0",
"contentHash": "diSC/ZeNdSdxHdYSOpYwuSBBDYpuNVtJQFJfiBB0WrYOQ4lVMmdxuUZJcViahQyo8pCvS3Mueo5lqFxwwMF/iw==",
"dependencies": {
"Grpc.Net.Common": "2.76.0"
}
},
"Grpc.AspNetCore.Server.ClientFactory": {
"type": "Transitive",
"resolved": "2.76.0",
"contentHash": "y5KGO1GO0N2L/hCCMR05mmoK8j+v8rKvZ+9nothAxKx2Tf2CwV8f4TM5K0GkKfDsp4vrc4lm90MU6E+DeN7YIw==",
"dependencies": {
"Grpc.AspNetCore.Server": "2.76.0",
"Grpc.Net.ClientFactory": "2.76.0"
}
},
"Grpc.Core.Api": {
"type": "Transitive",
"resolved": "2.76.0",
"contentHash": "cSxC2tdnFdXXuBgIn1pjc4YBx7LXTCp4M0qn+SMBS35VWZY+cEQYLWTBDDhdBH1HzU7BV+ncVZlniGQHMpRJKQ=="
},
"Grpc.Net.Client": {
"type": "Transitive",
"resolved": "2.76.0",
"contentHash": "K1oldmqw2+Gn69nGRzZLhqSiUZwelX1GrBu/cUl9wNf1C0uB61vFS6JcxUUv9P8VoUJhFsmV44JA6lI2EUt4xw==",
"dependencies": {
"Grpc.Net.Common": "2.76.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.0"
}
},
"Grpc.Net.ClientFactory": {
"type": "Transitive",
"resolved": "2.76.0",
"contentHash": "XI+kO69L9AV8B9N0UQOmH911r6MOEp9huHiavEsY56DJYuzJ9KAxNGy37dpV6CLbgCaN2uKmpOsZ9Pao6bmpVQ==",
"dependencies": {
"Grpc.Net.Client": "2.76.0",
"Microsoft.Extensions.Http": "8.0.0"
}
},
"Grpc.Net.Common": {
"type": "Transitive",
"resolved": "2.76.0",
"contentHash": "bZpiMVYgvpB44/wBh1RotrkqC7bg2FOasLri2GhR3hMKyzsiTxCoDE49YjPrJeFc4RW0wS8u+EInI09sjxVFRA==",
"dependencies": {
"Grpc.Core.Api": "2.76.0"
}
},
"Grpc.Tools": {
"type": "Transitive",
"resolved": "2.78.0",
"contentHash": "6jPG2gHon+w2PczW8jjrCRnW/g9eEfCdd7aK6mDooptWtuPsV3ZxAwKKEx7LGEDVoT4c2SViRl8Yu3L1XiWIIg=="
},
"Humanizer.Core": {
"type": "Transitive",
"resolved": "2.14.1",
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
},
"Json.More.Net": {
"type": "Transitive",
"resolved": "2.1.0",
"contentHash": "qtwsyAsL55y2vB2/sK4Pjg3ZyVzD5KKSpV3lOAMHlnjFfsjQ/86eHJfQT9aV1YysVXzF4+xyHOZbh7Iu3YQ7Lg=="
},
"JsonPatch.Net": {
"type": "Transitive",
"resolved": "3.3.0",
"contentHash": "GIcMMDtzfzVfIpQgey8w7dhzcw6jG5nD4DDAdQCTmHfblkCvN7mI8K03to8YyUhKMl4PTR6D6nLSvWmyOGFNTg==",
"dependencies": {
"JsonPointer.Net": "5.2.0"
}
},
"JsonPointer.Net": {
"type": "Transitive",
"resolved": "5.2.0",
"contentHash": "qe1F7Tr/p4mgwLPU9P60MbYkp+xnL2uCPnWXGgzfR/AZCunAZIC0RZ32dLGJJEhSuLEfm0YF/1R3u5C7mEVq+w==",
"dependencies": {
"Humanizer.Core": "2.14.1",
"Json.More.Net": "2.1.0"
}
},
"KubernetesClient": {
"type": "Transitive",
"resolved": "18.0.13",
"contentHash": "X5IuxmydftB148XeULtc7rD5/RvqLuW5SzkIjFovPgJpvV4RAoRqNPruVB7GEFu1Xg+zHVIk88WqdV8JjbgHbA==",
"dependencies": {
"Fractions": "7.3.0",
"YamlDotNet": "16.3.0"
}
},
"MessagePack": {
"type": "Transitive",
"resolved": "2.5.192",
"contentHash": "Jtle5MaFeIFkdXtxQeL9Tu2Y3HsAQGoSntOzrn6Br/jrl6c8QmG22GEioT5HBtZJR0zw0s46OnKU8ei2M3QifA==",
"dependencies": {
"MessagePack.Annotations": "2.5.192",
"Microsoft.NET.StringTools": "17.6.3"
}
},
"MessagePack.Annotations": {
"type": "Transitive",
"resolved": "2.5.192",
"contentHash": "jaJuwcgovWIZ8Zysdyf3b7b34/BrADw4v82GaEZymUhDd3ScMPrYd/cttekeDteJJPXseJxp04yTIcxiVUjTWg=="
},
"Microsoft.Extensions.AI.Abstractions": {
"type": "Transitive",
"resolved": "10.3.0",
"contentHash": "hDjDvUERvUH3HBMs2MDusOcGJBjAHOG5pJIU2x/HZEa4e1UthNKt89cwMi3B+ogJo6skki1XFjfgGN3ksnVqvQ=="
},
"Microsoft.Extensions.Caching.Abstractions": {
"type": "Transitive",
"resolved": "10.0.3",
"contentHash": "5dtXBvI8t3z8pF4tB38JYgi/enCL/DwSXxpqShgFz3SHJ7IzqFIMs6Gu5ik8sNZzcO9qQs3xIDpB3vDamkYG+Q==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.3"
}
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "8Rx5sqg04FttxrumyG6bmoRuFRgYzK6IVwF1i0/o0cXfKBdDeVpJejKHtJCMjyg9E/DNMVqpqOGe/tCT5gYvVA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "P09QpTHjqHmCLQOTC+WyLkoRNxek4NIvfWt+TnU0etoDUSRxcltyd6+j/ouRbMdLR0j44GqGO+lhI2M4fAHG4g==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "99Z4rjyXopb1MIazDSPcvwYCUdYNO01Cf1GUs2WUjIFAbkGmwzj2vPa2k+3pheJRV+YgNd2QqRKHAri0oBAU4Q==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.CommandLine": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "or9fOLopMUTJOQVJ3bou4aD6PwvsiKf4kZC4EE5sRRKSkmh+wfk/LekJXRjAX88X+1JA9zHjDo+5fiQ7z3MY/A==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.EnvironmentVariables": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "tchMGQ+zVTO40np/Zzg2Li/TIR8bksQgg4UVXZa0OzeFCKWnIYtxE2FVs+eSmjPGCjMS2voZbwN/mUcYfpSTuA==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.FileExtensions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "OhTr0O79dP49734lLTqVveivVX9sDXxbI/8vjELAZTHXqoN90mdpgTAgwicJED42iaHMCcZcK6Bj+8wNyBikaw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Physical": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.Json": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "brBM/WP0YAUYh2+QqSYVdK8eQHYQTtTEUJXJ+84Zkdo2buGLja9VSrMIhgoeBUU7JBmcskAib8Lb/N83bvxgYQ==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.FileExtensions": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.UserSecrets": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "fhdG6UV9lIp70QhNkVyaHciUVq25IPFkczheVJL9bIFvmnJ+Zghaie6dWkDbbVmxZlHl9gj3zTDxMxJs5zNhIA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Json": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Physical": "10.0.5"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "v1SVsowG6YE1YnHVGmLWz57YTRCQRx9pH5ebIESXfm5isI9gA3QaMyg/oMTzPpXYZwSAVDzYItGJKfmV+pqXkQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA=="
},
"Microsoft.Extensions.Diagnostics": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "vAJHd4yOpmKoK+jBuYV7a3y+Ab9U4ARCc29b6qvMy276RgJFw9LFs0DdsPqOL3ahwzyrX7tM+i4cCxU/RX0qAg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.5"
}
},
"Microsoft.Extensions.Diagnostics.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "/nYGrpa9/0BZofrVpBbbj+Ns8ZesiPE0V/KxsuHgDgHQopIzN54nRaQGSuvPw16/kI9sW1Zox5yyAPqvf0Jz6A==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Diagnostics.HealthChecks": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "REdt95QXHscGdtw/UUgyCW2lF9DJcAOJxmebKW2IkgUjuCAdMODIi2HNOWg5utW98nm8ekgV0Gjqs/sljwwqMw==",
"dependencies": {
"Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "NrIMTy7dpqxAvA6kHAYH8cXID/YgeNOy0OqFKpLtkPu5X4WS/basX91UszANzVrMNRAICJ2GOnGiRxJtsRyEQw=="
},
"Microsoft.Extensions.FileProviders.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "nCBmCx0Xemlu65ZiWMcXbvfvtznKxf4/YYKF9R28QkqdI9lTikedGqzJ28/xmdGGsxUnsP5/3TQGpiPwVjK0dA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.FileProviders.Physical": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "dMu5kUPSfol1Rqhmr6nWPSmbFjDe9w6bkoKithG17bWTZA0UyKirTatM5mqYUN3mGpNA0MorlusIoVTh6J7o5g==",
"dependencies": {
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileSystemGlobbing": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.FileSystemGlobbing": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "mOE3ARusNQR0a5x8YOcnUbfyyXGqoAWQtEc7qFOfNJgruDWQLo39Re+3/Lzj5pLPFuFYj8hN4dgKzaSQDKiOCw=="
},
"Microsoft.Extensions.Hosting": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "8i7e5IBdiKLNqt/+ciWrS8U95Rv5DClaaj7ulkZbimnCi4uREWd+lXzkp3joofFuIPOlAzV4AckxLTIELv2jdg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.Configuration.CommandLine": "10.0.5",
"Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.5",
"Microsoft.Extensions.Configuration.FileExtensions": "10.0.5",
"Microsoft.Extensions.Configuration.Json": "10.0.5",
"Microsoft.Extensions.Configuration.UserSecrets": "10.0.5",
"Microsoft.Extensions.DependencyInjection": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Physical": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Configuration": "10.0.5",
"Microsoft.Extensions.Logging.Console": "10.0.5",
"Microsoft.Extensions.Logging.Debug": "10.0.5",
"Microsoft.Extensions.Logging.EventLog": "10.0.5",
"Microsoft.Extensions.Logging.EventSource": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Hosting.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "+Wb7KAMVZTomwJkQrjuPTe5KBzGod7N8XeG+ScxRlkPOB4sZLG4ccVwjV4Phk5BCJt7uIMnGHVoN6ZMVploX+g==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Http": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "AiFvHYM8nP0wPC7bGPI3NHQlSYSLqjjT7DMJUuuxhd+7pz3O89iu2gdQfgACy5DxsXENiok5i1bMacJL7KR8jA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "+XTMKQyDWg4ODoNHU/BN3BaI1jhGO7VCS+BnzT/4IauiG6y2iPAte7MyD7rHKS+hNP0TkFkjrae8DFjDUxtcxg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "9HOdqlDtPptVcmKAjsQ/Nr5Rxfq6FMYLdhvZh1lVmeKR738qeYecQD7+ldooXf+u2KzzR1kafSphWngIM3C6ug==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Configuration": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "cSgxsDgfP0+gmVRPVoNHI/KIDavIZxh+CxE6tSLPlYTogqccDnjBFI9CgEsiNuMP6+fiuXUwhhlTz36uUEpwbQ==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Console": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "PMs2gha2v24hvH5o5KQem5aNK4mN0BhhCWlMqsg9tzifWKzjeQi2tyPOP/RaWMVvalOhVLcrmoMYPqbnia/epg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Configuration": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Debug": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "/VacEkBQ02A8PBXSa6YpbIXCuisYy6JJr62/+ANJDZE+RMBfZMcXJXLfr/LpyLE6pgdp17Wxlt7e7R9zvkwZ3Q==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Logging.EventLog": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "0ezhWYJS4/6KrqQel9JL+Tr4n+4EX2TF5EYiaysBWNNEM2c3Gtj1moD39esfgk8OHblSX+UFjtZ3z0c4i9tRvw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"System.Diagnostics.EventLog": "10.0.5"
}
},
"Microsoft.Extensions.Logging.EventSource": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "vN+aq1hBFXyYvY5Ow9WyeR66drKQxRZmas4lAjh6QWfryPkjTn1uLtX5AFIxyDaZj78v5TG2sELUyvrXpAPQQw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "MDaQMdUplw0AIRhWWmbLA7yQEXaLIHb+9CTroTiNS8OlI0LMXS4LCxtopqauiqGCWlRgJ+xyraVD8t6veRAFbw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Options.ConfigurationExtensions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "BB9uUW3+6Rxu1R97OB1H/13lUF8P2+H1+eDhpZlK30kDh/6E4EKHBUqTp+ilXQmZLzsRErxON8aBSR6WpUKJdg==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "/HUHJ0tw/LQvD0DZrz50eQy/3z7PfX7WWEaXnjKTV9/TNdcgFlNTZGo49QhS7PTmhDqMyHRMqAXSBxLh0vso4g=="
},
"Microsoft.NET.StringTools": {
"type": "Transitive",
"resolved": "17.6.3",
"contentHash": "N0ZIanl1QCgvUumEL1laasU0a7sOE5ZwLZVTn0pAePnfhq8P7SvTjF8Axq+CnavuQkmdQpGNXQ1efZtu5kDFbA=="
},
"Microsoft.VisualStudio.Threading.Only": {
"type": "Transitive",
"resolved": "17.13.61",
"contentHash": "vl5a2URJYCO5m+aZZtNlAXAMz28e2pUotRuoHD7RnCWOCeoyd8hWp5ZBaLNYq4iEj2oeJx5ZxiSboAjVmB20Qg==",
"dependencies": {
"Microsoft.VisualStudio.Validation": "17.8.8"
}
},
"Microsoft.VisualStudio.Validation": {
"type": "Transitive",
"resolved": "17.8.8",
"contentHash": "rWXThIpyQd4YIXghNkiv2+VLvzS+MCMKVRDR0GAMlflsdo+YcAN2g2r5U1Ah98OFjQMRexTFtXQQ2LkajxZi3g=="
},
"ModelContextProtocol": {
"type": "Transitive",
"resolved": "1.0.0",
"contentHash": "W7UX8AQ1qMjXyCDcpP25u/L1W2vIIgfhLX/B2ZtTU1VUyILXdmVbdRjkQesKVPT/wPMpYXIHUcZJTPdsGfKSfQ==",
"dependencies": {
"Microsoft.Extensions.Caching.Abstractions": "10.0.3",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.3",
"ModelContextProtocol.Core": "1.0.0"
}
},
"ModelContextProtocol.Core": {
"type": "Transitive",
"resolved": "1.0.0",
"contentHash": "QKboiQEq2MJMGeQ029Gy6xqge88abm0Px9lnG7hueOyf+EDCxi5SUATV+Df7GwT+NwWzkEsYG271bUQD+LGhEg==",
"dependencies": {
"Microsoft.Extensions.AI.Abstractions": "10.3.0",
"Microsoft.Extensions.Logging.Abstractions": "10.0.3"
}
},
"Nerdbank.Streams": {
"type": "Transitive",
"resolved": "2.12.87",
"contentHash": "oDKOeKZ865I5X8qmU3IXMyrAnssYEiYWTobPGdrqubN3RtTzEHIv+D6fwhdcfrdhPJzHjCkK/ORztR/IsnmA6g==",
"dependencies": {
"Microsoft.VisualStudio.Threading.Only": "17.13.61",
"Microsoft.VisualStudio.Validation": "17.8.8"
}
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.4",
"contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A=="
},
"Npgsql": {
"type": "Transitive",
"resolved": "8.0.3",
"contentHash": "6WEmzsQJCZAlUG1pThKg/RmeF6V+I0DmBBBE/8YzpRtEzhyZzKcK7ulMANDm5CkxrALBEC8H+5plxHWtIL7xnA==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "8.0.0"
}
},
"Polly.Core": {
"type": "Transitive",
"resolved": "8.6.5",
"contentHash": "t+sUVrIwvo7UmsgHGgOG9F0GDZSRIm47u2ylH17Gvcv1q5hNEwgD5GoBlFyc0kh/pebmPyrAgvGsR/65ZBaXlg=="
},
"Semver": {
"type": "Transitive",
"resolved": "3.0.0",
"contentHash": "9jZCicsVgTebqkAujRWtC9J1A5EQVlu0TVKHcgoCuv345ve5DYf4D1MjhKEnQjdRZo6x/vdv6QQrYFs7ilGzLA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "5.0.1"
}
},
"StreamJsonRpc": {
"type": "Transitive",
"resolved": "2.22.23",
"contentHash": "Ahq6uUFPnU9alny5h4agyX74th3PRq3NQCRNaDOqWcx20WT06mH/wENSk5IbHDc8BmfreQVEIBx5IXLBbsLFIA==",
"dependencies": {
"MessagePack": "2.5.192",
"Microsoft.VisualStudio.Threading.Only": "17.13.61",
"Microsoft.VisualStudio.Validation": "17.8.8",
"Nerdbank.Streams": "2.12.87",
"Newtonsoft.Json": "13.0.3"
}
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "wugvy+pBVzjQEnRs9wMTWwoaeNFX3hsaHeVHFDIvJSWXp7wfmNWu3mxAwBIE6pyW+g6+rHa1Of5fTzb0QVqUTA=="
},
"System.IO.Hashing": {
"type": "Transitive",
"resolved": "10.0.3",
"contentHash": "La6ICwsdTKhVX+LKN+pvFjQRR3LhLwq3uKdi2knjLzRyPYBSydF4cjXidYxIiTcDD6XVYdsBWQEI8ZxiZ/OdIg=="
},
"YamlDotNet": {
"type": "Transitive",
"resolved": "16.3.0",
"contentHash": "SgMOdxbz8X65z8hraIs6hOEdnkH6hESTAIUa7viEngHOYaH+6q5XJmwr1+yb9vJpNQ19hCQY69xbFsLtXpobQA=="
}
}
}
}
+8
View File
@@ -30,8 +30,16 @@ RUN dotnet publish "GmRelay.Bot.csproj" -c Release -a $TARGETARCH -o /app/publis
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble AS final
WORKDIR /app
# Устанавливаем wget для healthcheck
RUN apt-get update && apt-get install -y --no-install-recommends wget \
&& rm -rf /var/lib/apt/lists/*
# Копируем только AOT-результаты из билда
COPY --from=build /app/publish .
EXPOSE 8081
USER $APP_UID
# Запуск скомпилированного AOT бинарного файла напрямую
ENTRYPOINT ["./GmRelay.Bot"]
@@ -1,330 +0,0 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Confirmation.HandleRsvp;
// ── Command ──────────────────────────────────────────────────────────
public sealed record HandleRsvpCommand(
Guid SessionId,
long TelegramUserId,
string Status,
string CallbackQueryId,
long ChatId,
int MessageId);
// ── DTOs ─────────────────────────────────────────────────────────────
internal sealed record RsvpCounts(int Total, int Confirmed, int Declined);
internal sealed record SessionContext(
string Title,
DateTime ScheduledAt,
long GmTelegramId,
long TelegramChatId);
internal sealed record ParticipantRsvp(
long TelegramId,
string DisplayName,
string? TelegramUsername,
string RsvpStatus);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary>
/// Handles the "Буду" / "Не смогу" callback query.
///
/// Flow:
/// 1. Validate that the user is a participant in this session
/// 2. Record or update their RSVP (idempotent)
/// 3. If declined → alert GM privately, revert session if was Confirmed
/// 4. If all non-GM players confirmed → mark session Confirmed, notify group + GM
/// 5. Update the inline keyboard to show current RSVP status
///
/// Concurrency: two simultaneous clicks on different rows don't conflict (MVCC).
/// The last EditMessage wins, which is fine — both reflect up-to-date state.
/// </summary>
public sealed class HandleRsvpHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
ILogger<HandleRsvpHandler> logger)
{
public async Task HandleAsync(HandleRsvpCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
// ── 1. Validate participant ──────────────────────────────────
var participantExists = await connection.ExecuteScalarAsync<bool>(
"""
SELECT EXISTS (
SELECT 1 FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.telegram_id = @TelegramUserId
AND sp.is_gm = false
)
""",
new { command.SessionId, command.TelegramUserId },
transaction);
if (!participantExists)
{
await bot.AnswerCallbackQuery(
callbackQueryId: command.CallbackQueryId,
text: "Вы не являетесь участником этой сессии.",
cancellationToken: ct);
return;
}
// ── 2. Record RSVP (idempotent) ─────────────────────────────
var updated = await connection.ExecuteAsync(
"""
UPDATE session_participants
SET rsvp_status = @Status,
responded_at = now()
WHERE session_id = @SessionId
AND player_id = (SELECT id FROM players WHERE telegram_id = @TelegramUserId)
AND rsvp_status != @Status
""",
new { command.SessionId, command.TelegramUserId, command.Status },
transaction);
if (updated == 0)
{
// Already in this state — just dismiss the loading spinner
var alreadyText = command.Status == RsvpStatus.Confirmed
? "Вы уже подтвердили участие."
: "Вы уже отказались от участия.";
await bot.AnswerCallbackQuery(
callbackQueryId: command.CallbackQueryId,
text: alreadyText,
cancellationToken: ct);
return;
}
// ── 3. Load session context ─────────────────────────────────
var session = await connection.QuerySingleAsync<SessionContext>(
"""
SELECT s.title, s.scheduled_at AS ScheduledAt,
g.gm_telegram_id AS GmTelegramId,
g.telegram_chat_id AS TelegramChatId
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId
""",
new { command.SessionId },
transaction);
// ── 4. Handle decline ───────────────────────────────────────
if (command.Status == RsvpStatus.Declined)
{
// Revert session to ConfirmationSent if it was Confirmed
await connection.ExecuteAsync(
"""
UPDATE sessions
SET status = @ConfirmationSent, updated_at = now()
WHERE id = @SessionId AND status = @Confirmed
""",
new
{
command.SessionId,
ConfirmationSent = SessionStatus.ConfirmationSent,
Confirmed = SessionStatus.Confirmed
},
transaction);
// Alert GM immediately via private message
var declinedPlayer = await connection.QuerySingleAsync<string>(
"SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
new { command.TelegramUserId },
transaction);
await transaction.CommitAsync(ct);
// Send alert outside transaction (network call)
try
{
await bot.SendMessage(
chatId: session.GmTelegramId,
text: $"🚨 Отмена! {declinedPlayer} не сможет прийти на игру «{session.Title}».",
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}",
command.SessionId);
}
await bot.AnswerCallbackQuery(
callbackQueryId: command.CallbackQueryId,
text: "Вы отказались от участия.",
cancellationToken: ct);
}
// ── 5. Handle confirm — check if ALL confirmed ──────────────
else
{
var counts = await connection.QuerySingleAsync<RsvpCounts>(
"""
SELECT
count(*) AS Total,
count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed,
count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined
FROM session_participants
WHERE session_id = @SessionId AND is_gm = false
""",
new
{
command.SessionId,
Confirmed = RsvpStatus.Confirmed,
Declined = RsvpStatus.Declined
},
transaction);
var allConfirmed = counts.Confirmed == counts.Total;
if (allConfirmed)
{
await connection.ExecuteAsync(
"""
UPDATE sessions
SET status = @Confirmed, updated_at = now()
WHERE id = @SessionId
""",
new { command.SessionId, Confirmed = SessionStatus.Confirmed },
transaction);
}
await transaction.CommitAsync(ct);
if (allConfirmed)
{
// Notify group
try
{
await bot.SendMessage(
chatId: session.TelegramChatId,
text: $"🎉 Игра «{session.Title}» подтверждена! Все участники на месте.",
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}",
command.SessionId);
}
// Notify GM privately
try
{
await bot.SendMessage(
chatId: session.GmTelegramId,
text: $"✅ Все подтвердили участие в «{session.Title}» ({session.ScheduledAt.FormatMoscow()} МСК).",
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}",
command.SessionId);
}
}
await bot.AnswerCallbackQuery(
callbackQueryId: command.CallbackQueryId,
text: "Вы подтвердили участие!",
cancellationToken: ct);
}
// ── 6. Update inline keyboard message ───────────────────────
await UpdateConfirmationMessage(command, session, ct);
}
/// <summary>
/// Re-renders the confirmation message with current RSVP statuses.
/// </summary>
private async Task UpdateConfirmationMessage(
HandleRsvpCommand command, SessionContext session, CancellationToken ct)
{
try
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var participants = (await connection.QueryAsync<ParticipantRsvp>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
sp.rsvp_status AS RsvpStatus
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false
ORDER BY sp.responded_at NULLS LAST
""",
new { command.SessionId })).ToList();
var confirmed = participants.Where(p => p.RsvpStatus == RsvpStatus.Confirmed).ToList();
var declined = participants.Where(p => p.RsvpStatus == RsvpStatus.Declined).ToList();
var pending = participants.Where(p => p.RsvpStatus == RsvpStatus.Pending).ToList();
var lines = new List<string>
{
$"🎲 Подтвердите участие в «{session.Title}»",
$"📅 {session.ScheduledAt.FormatMoscow()} (МСК)",
""
};
foreach (var p in confirmed)
lines.Add($" ✅ {FormatName(p)}");
foreach (var p in declined)
lines.Add($" ❌ ~~{FormatName(p)}~~");
foreach (var p in pending)
lines.Add($" ⏳ {FormatName(p)}");
lines.Add("");
if (confirmed.Count == participants.Count)
lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{participants.Count})");
else if (declined.Count > 0)
lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{participants.Count} подтвердили)");
else
lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{participants.Count})");
var text = string.Join("\n", lines);
// Keep buttons unless everyone confirmed
var replyMarkup = confirmed.Count == participants.Count
? null
: new InlineKeyboardMarkup([
[
InlineKeyboardButton.WithCallbackData("\u2705 Буду", $"rsvp:confirm:{command.SessionId}"),
InlineKeyboardButton.WithCallbackData("\u274c Не смогу", $"rsvp:decline:{command.SessionId}")
]
]);
await bot.EditMessageText(
chatId: command.ChatId,
messageId: command.MessageId,
text: text,
replyMarkup: replyMarkup,
cancellationToken: ct);
}
catch (Exception ex)
{
// EditMessage can fail if message is too old or unchanged — non-critical
logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}",
command.SessionId);
}
}
private static string FormatName(ParticipantRsvp p) =>
p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName;
}
@@ -1,123 +0,0 @@
using Dapper;
using GmRelay.Shared.Domain;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Confirmation.SendConfirmation;
// ── DTOs for Dapper mapping ──────────────────────────────────────────
internal sealed record SessionInfo(
Guid Id,
string Title,
DateTime ScheduledAt,
Guid GroupId,
long TelegramChatId);
internal sealed record ParticipantInfo(
long TelegramId,
string DisplayName,
string? TelegramUsername);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary>
/// Sends the interactive confirmation message (inline keyboard) to the group chat.
/// Called by SessionSchedulerService at T-24h.
/// </summary>
public sealed class SendConfirmationHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
ILogger<SendConfirmationHandler> logger)
{
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
// 1. Load session + group info
var session = await connection.QuerySingleOrDefaultAsync<SessionInfo>(
"""
SELECT s.id, s.title, s.scheduled_at AS ScheduledAt, s.group_id AS GroupId,
g.telegram_chat_id AS TelegramChatId
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.status = @Planned
""",
new { SessionId = sessionId, Planned = SessionStatus.Planned });
if (session is null)
{
logger.LogWarning("Session {SessionId} not found or not in Planned status", sessionId);
return;
}
// 2. Load non-GM participants
var participants = (await connection.QueryAsync<ParticipantInfo>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false
""",
new { SessionId = sessionId })).ToList();
if (participants.Count == 0)
{
logger.LogWarning("Session {SessionId} has no non-GM participants", sessionId);
return;
}
// 3. Build confirmation message
var playerList = string.Join("\n", participants.Select(p =>
$" ⏳ {FormatPlayerName(p)}"));
var text = $"""
🎲 Подтвердите участие в «{session.Title}»
📅 {session.ScheduledAt.FormatMoscow()} (МСК)
{playerList}
Статус: ожидаем подтверждения (0/{participants.Count})
""";
var keyboard = new InlineKeyboardMarkup([
[
InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{sessionId}"),
InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{sessionId}")
]
]);
// 4. Send to group
var message = await bot.SendMessage(
chatId: session.TelegramChatId,
text: text,
replyMarkup: keyboard,
cancellationToken: ct);
// 5. Update session status and store message ID
await connection.ExecuteAsync(
"""
UPDATE sessions
SET status = @Status,
confirmation_message_id = @MessageId,
updated_at = now()
WHERE id = @SessionId
""",
new
{
SessionId = sessionId,
Status = SessionStatus.ConfirmationSent,
MessageId = message.MessageId
});
logger.LogInformation(
"Confirmation sent for session {SessionId} ({Title}), message_id={MessageId}",
sessionId, session.Title, message.MessageId);
}
internal static string FormatPlayerName(ParticipantInfo p) =>
p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName;
}
@@ -0,0 +1,41 @@
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Features.Notifications;
public sealed record DirectNotificationRecipient(long TelegramId, string DisplayName);
public sealed class DirectSessionNotificationSender(
IPlatformMessenger messenger,
ILogger<DirectSessionNotificationSender> logger)
{
public async Task SendAsync(
IEnumerable<DirectNotificationRecipient> recipients,
string htmlText,
string notificationKind,
Guid sessionId,
CancellationToken ct)
{
foreach (var recipient in recipients)
{
try
{
await messenger.SendPrivateMessageAsync(
new PlatformPrivateMessage(
TelegramPlatformIds.User(recipient.TelegramId, recipient.DisplayName),
htmlText),
ct);
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"Failed to send {NotificationKind} DM for session {SessionId} to player {TelegramId} ({DisplayName})",
notificationKind,
sessionId,
recipient.TelegramId,
recipient.DisplayName);
}
}
}
}
@@ -1,103 +0,0 @@
using Dapper;
using GmRelay.Shared.Domain;
using Npgsql;
using Telegram.Bot;
namespace GmRelay.Bot.Features.Reminders.SendJoinLink;
// ── DTOs ─────────────────────────────────────────────────────────────
internal sealed record JoinLinkSession(
Guid Id,
string Title,
string JoinLink,
DateTime ScheduledAt,
long TelegramChatId);
internal sealed record ConfirmedPlayer(
long TelegramId,
string DisplayName,
string? TelegramUsername);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary>
/// Sends the join link to the group chat at T-5min, tagging all confirmed players.
/// Called by SessionSchedulerService.
/// </summary>
public sealed class SendJoinLinkHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
ILogger<SendJoinLinkHandler> logger)
{
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
// 1. Load session
var session = await connection.QuerySingleOrDefaultAsync<JoinLinkSession>(
"""
SELECT s.id, s.title, s.join_link AS JoinLink, s.scheduled_at AS ScheduledAt,
g.telegram_chat_id AS TelegramChatId
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId
AND s.status = @Confirmed
AND s.link_message_id IS NULL
""",
new { SessionId = sessionId, Confirmed = SessionStatus.Confirmed });
if (session is null)
{
logger.LogWarning("Session {SessionId} not eligible for join link", sessionId);
return;
}
// 2. Load confirmed players
var players = (await connection.QueryAsync<ConfirmedPlayer>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.rsvp_status = @Confirmed
""",
new { SessionId = sessionId, Confirmed = RsvpStatus.Confirmed })).ToList();
// 3. Build message with player mentions
var mentions = string.Join(", ", players.Select(p =>
p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName));
var text = $"""
🎮 Игра «{session.Title}» начинается через 5 минут!
🔗 Ссылка на подключение:
{session.JoinLink}
Участники: {mentions}
Хорошей игры! 🎲
""";
// 4. Send
var message = await bot.SendMessage(
chatId: session.TelegramChatId,
text: text,
cancellationToken: ct);
// 5. Mark as sent (idempotent — link_message_id IS NULL guard in query)
await connection.ExecuteAsync(
"""
UPDATE sessions
SET link_message_id = @MessageId, updated_at = now()
WHERE id = @SessionId AND link_message_id IS NULL
""",
new { SessionId = sessionId, MessageId = message.MessageId });
logger.LogInformation(
"Join link sent for session {SessionId} ({Title}), message_id={MessageId}",
sessionId, session.Title, message.MessageId);
}
}
@@ -1,9 +1,10 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -12,82 +13,135 @@ public sealed record CancelSessionCommand(
long TelegramUserId,
string CallbackQueryId,
long ChatId,
int? MessageThreadId,
int MessageId);
// DTOs for AOT compilation
internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, long GmId);
internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, int? BatchMessageId, bool CanManage, string NotificationMode);
public sealed class CancelSessionHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
DirectSessionNotificationSender directSender,
ILogger<CancelSessionHandler> logger)
{
public async Task HandleAsync(CancelSessionCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
// 1. Проверяем, что запрос делает ГМ данной сессии
// 1. Проверяем, что запрос делает управляющий данной группы.
var session = await connection.QuerySingleOrDefaultAsync<CancelSessionInfoDto>(
@"SELECT s.title as Title, s.batch_id as BatchId, g.gm_telegram_id as GmId
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
WHERE s.id = @SessionId",
new { command.SessionId }, transaction);
"""
SELECT s.title AS Title,
s.batch_id AS BatchId,
s.batch_message_id AS BatchMessageId,
s.notification_mode AS NotificationMode,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.platform = 'Telegram'
AND p.external_user_id = @ExternalUserId
) AS CanManage
FROM sessions s
WHERE s.id = @SessionId
""",
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString() }, transaction);
if (session == null)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
return;
}
if (session.GmId != command.TelegramUserId)
if (!session.CanManage)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может отменять сессию.", showAlert: true, cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Только owner или co-GM может отменять сессию.", ct, showAlert: true);
return;
}
// 2. Отменяем сессию
await connection.ExecuteAsync("UPDATE sessions SET status = 'Cancelled' WHERE id = @Id", new { Id = command.SessionId }, transaction);
await connection.ExecuteAsync(
"UPDATE sessions SET status = @Status WHERE id = @Id",
new { Id = command.SessionId, Status = SessionStatus.Cancelled },
transaction);
// 3. Загружаем весь батч для перерисовки
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink
FROM sessions
WHERE batch_id = @BatchId
ORDER BY scheduled_at",
new { BatchId = session.BatchId }, transaction);
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
@"SELECT sp.session_id as SessionId, p.display_name as DisplayName, p.telegram_username as TelegramUsername
@"SELECT sp.session_id as SessionId,
p.display_name as DisplayName,
p.telegram_username as TelegramUsername,
sp.registration_status as RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
JOIN sessions s ON sp.session_id = s.id
WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.responded_at ASC, p.created_at ASC",
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC",
new { BatchId = session.BatchId }, transaction);
var directRecipients = (await connection.QueryAsync<DirectNotificationRecipient>(
"""
SELECT p.external_user_id::BIGINT AS TelegramId,
p.display_name AS DisplayName
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { command.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
await transaction.CommitAsync(ct);
// 4. Перерисовываем сообщение
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions.ToList(), batchParticipants.ToList());
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions.ToList(), batchParticipants.ToList());
try
{
await bot.EditMessageText(
chatId: command.ChatId,
messageId: command.MessageId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
var messageId = session.BatchMessageId ?? command.MessageId;
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId),
view,
TelegramPlatformIds.Message(command.ChatId, command.MessageThreadId, messageId)),
ct);
await AnswerAsync(command.CallbackQueryId, "Сессия отменена!", ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия отменена!", cancellationToken: ct);
// Опционально: написать отдельное сообщение в чат
await bot.SendMessage(command.ChatId, $"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, cancellationToken: ct);
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId),
$"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.",
ct);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
await directSender.SendAsync(
directRecipients,
$"❌ <b>Сессия отменена</b>\n\n📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>",
"session-cancelled",
command.SessionId,
ct);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to update batch message after cancelling session {SessionId}", command.SessionId);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Ошибка при обновлении сообщения.", cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Ошибка при обновлении сообщения.", ct);
}
}
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
}
@@ -1,153 +1,260 @@
using System.Text.RegularExpressions;
using Dapper;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
public sealed class CreateSessionHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient botClient,
ILogger<CreateSessionHandler> logger)
/// <summary>
/// Telegram-side entry point for the wizard-driven session creation
/// flow. Talks to the shared wizard through <see cref="IWizardMessenger"/>
/// and the platform-neutral <see cref="WizardDraft"/>. Keeps the
/// platform glue (mapping <c>Message</c> to draft fields, rendering
/// error keyboards, etc.) local to <c>GmRelay.Bot</c>.
/// </summary>
public sealed class CreateSessionHandler
{
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
{
var text = message.Text ?? "";
string? title = null;
string? link = null;
var scheduledTimes = new List<DateTimeOffset>();
private const int MaxRetries = 3;
private const string PlatformName = "Telegram";
foreach (var line in text.Split('\n'))
private readonly IWizardDraftRepository _drafts;
private readonly SharedCreateSessionHandler _shared;
private readonly IWizardMessenger _messenger;
private readonly ILogger<CreateSessionHandler> _log;
public CreateSessionHandler(
IWizardDraftRepository drafts,
SharedCreateSessionHandler shared,
IWizardMessenger messenger,
ILogger<CreateSessionHandler> log)
{
_drafts = drafts;
_shared = shared;
_messenger = messenger;
_log = log;
}
/// <summary>
/// Entry point for <c>/newsession</c>. If a non-expired draft
/// already exists for this owner, returns <c>null</c> so the caller
/// can render a "Continue / Start over / Cancel" menu.
/// </summary>
public async Task<WizardDraft?> StartWizardAsync(Message message, CancellationToken ct)
{
var ownerId = (message.From?.Id ?? 0).ToString(CultureInfo.InvariantCulture);
var existing = await _drafts.GetActiveAsync(PlatformName, ownerId, ct);
if (existing is not null)
{
var trimmed = line.Trim();
if (trimmed.StartsWith("Название:", StringComparison.OrdinalIgnoreCase))
title = trimmed["Название:".Length..].Trim();
else if (trimmed.StartsWith("Ссылка:", StringComparison.OrdinalIgnoreCase))
link = trimmed["Ссылка:".Length..].Trim();
else if (trimmed.StartsWith("Время:", StringComparison.OrdinalIgnoreCase))
{
var timeStr = trimmed["Время:".Length..].Trim();
if (MoscowTime.TryParseMoscow(timeStr, out var scheduledAt))
{
if (scheduledAt > DateTimeOffset.UtcNow)
scheduledTimes.Add(scheduledAt);
else
await botClient.SendMessage(message.Chat.Id, $"⚠️ Предупреждение: Дата {timeStr} находится в прошлом и будет пропущена.", cancellationToken: cancellationToken);
}
else
{
await botClient.SendMessage(message.Chat.Id, $"⚠️ Предупреждение: Некорректный формат времени '{timeStr}'. Пропущено.", cancellationToken: cancellationToken);
}
}
return null;
}
if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(link) || scheduledTimes.Count == 0)
var draft = new WizardDraft
{
await botClient.SendMessage(
chatId: message.Chat.Id,
text: "❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:\n\n/newsession\nНазвание: My Game\nВремя: 15.05.2026 19:30\nВремя: 22.05.2026 19:30\nСсылка: https://link",
cancellationToken: cancellationToken);
Id = Guid.NewGuid(),
ChatId = message.Chat.Id.ToString(CultureInfo.InvariantCulture),
MessageThreadId = message.MessageThreadId?.ToString(CultureInfo.InvariantCulture),
OwnerId = ownerId,
Platform = PlatformName,
Step = WizardStepNames.Type,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddHours(24),
};
await _drafts.UpsertAsync(draft, ct);
var (text, actions) = WizardStepViewBuilder.Build(draft, new WizardPayload());
var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
draft.DraftMessageId = msgId;
draft.UpdatedAt = DateTime.UtcNow;
await _drafts.UpsertAsync(draft, ct);
return draft;
}
/// <summary>
/// Resume an existing draft — returns the draft row so the caller
/// can re-render the resume/reset menu.
/// </summary>
public Task<WizardDraft?> TryResumeAsync(Message message, CancellationToken ct)
{
var ownerId = (message.From?.Id ?? 0).ToString(CultureInfo.InvariantCulture);
return _drafts.GetActiveAsync(PlatformName, ownerId, ct);
}
/// <summary>
/// Finalize: build shared command(s), call the shared handler, edit
/// the wizard message. On failure, retry up to <see cref="MaxRetries"/>
/// times before deleting the draft.
/// </summary>
public async Task SubmitDraftAsync(WizardDraft draft, CancellationToken ct)
{
var payload = LoadPayload(draft);
if (!IsComplete(payload, out var missing))
{
await _messenger.EditDraftMessageAsync(
draft, $"❌ Не заполнены поля: {missing}", Array.Empty<WizardAction>(), ct);
return;
}
var gmId = message.From!.Id;
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? "" : $" {message.From.LastName}");
var gmUsername = message.From.Username;
var chatId = message.Chat.Id;
var chatTitle = message.Chat.Title ?? "Private Chat";
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var commands = BuildCommands(draft, payload);
try
{
// 1. Убеждаемся, что GM зарегистрирован
await connection.ExecuteAsync(
@"INSERT INTO players (telegram_id, display_name, telegram_username)
VALUES (@TgId, @Name, @Username)
ON CONFLICT (telegram_id) DO UPDATE SET display_name = EXCLUDED.display_name, telegram_username = EXCLUDED.telegram_username;",
new { TgId = gmId, Name = gmName, Username = gmUsername },
transaction);
// 2. Убеждаемся, что Группа зарегистрирована
var groupId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id)
VALUES (@ChatId, @ChatName, @GmId)
ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name
RETURNING id;",
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
transaction);
int? messageThreadId = null;
if (message.Chat.IsForum)
foreach (var cmd in commands)
{
var topic = await botClient.CreateForumTopic(
chatId: chatId,
name: $"🎲 Игры: {title}",
cancellationToken: cancellationToken);
messageThreadId = topic.MessageThreadId;
}
// 3. Создаем сессии в цикле с общим batch_id
var batchId = Guid.NewGuid();
var sessions = new List<SessionBatchDto>();
foreach (var dt in scheduledTimes.OrderBy(d => d))
{
var sessionId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, 'Planned', @ThreadId)
RETURNING id;",
new { BatchId = batchId, GroupId = groupId, Title = title, Link = link, ScheduledAt = dt, ThreadId = messageThreadId },
transaction);
sessions.Add(new SessionBatchDto(sessionId, dt.UtcDateTime, "Planned"));
}
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
// 4. Отправляем сообщение в чат
var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty<ParticipantBatchDto>());
var batchMessage = await botClient.SendMessage(
chatId: chatId,
messageThreadId: messageThreadId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
// 4b. Сохраняем message_id батч-сообщения для дальнейшего редактирования
await connection.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
new { MsgId = batchMessage.MessageId, BatchId = batchId });
// 5. Удаляем исходное сообщение с командой /newsession, чтобы не спамить
try
{
await botClient.DeleteMessage(
chatId: chatId,
messageId: message.MessageId,
cancellationToken: cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Не удалось удалить исходное сообщение {MessageId} в чате {ChatId}", message.MessageId, chatId);
await _shared.HandleAsync(cmd, ct);
}
var totalSessions = commands.Sum(c => c.ScheduledTimes.Count);
await _messenger.EditDraftMessageAsync(
draft,
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
Array.Empty<WizardAction>(),
ct);
await _drafts.DeleteAsync(draft.Id, ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка при создании сессии");
await transaction.RollbackAsync(cancellationToken);
await botClient.SendMessage(chatId, "💥 Произошла ошибка базы данных при создании сессии.", cancellationToken: cancellationToken);
_log.LogError(ex, "SubmitDraftAsync failed for draft {DraftId}", draft.Id);
payload.RetryCount += 1;
SavePayload(draft, payload);
if (payload.RetryCount >= MaxRetries)
{
await _messenger.EditDraftMessageAsync(
draft,
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession, чтобы начать заново.",
Array.Empty<WizardAction>(),
ct);
await _drafts.DeleteAsync(draft.Id, ct);
return;
}
draft.UpdatedAt = DateTime.UtcNow;
await _drafts.UpsertAsync(draft, ct);
await _messenger.EditDraftMessageAsync(
draft,
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
RetryCancelActions(),
ct);
}
}
// ── Build shared commands ────────────────────────────────────────
// The shared handler creates one session per scheduled time in a
// single transaction and assigns the same batch_id to all of them.
// A wizard pool therefore produces ONE command with N times; a
// single-game wizard produces ONE command with one time.
private static List<CreateSessionCommand> BuildCommands(WizardDraft draft, WizardPayload p)
{
if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0)
{
return new List<CreateSessionCommand>
{
BuildCommand(
draft,
p,
pool.Slots.Select(s => s.ScheduledAt).ToList(),
MaxPlayersForPool(pool),
isOneShot: false),
};
}
return new List<CreateSessionCommand>
{
BuildCommand(
draft,
p,
new[] { p.Single?.ScheduledAt ?? default },
p.Single?.MaxPlayers,
isOneShot: true),
};
}
private static int MaxPlayersForPool(WizardPoolInput pool) =>
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
internal static CreateSessionCommand BuildCommand(
WizardDraft draft,
WizardPayload p,
IReadOnlyList<DateTimeOffset> scheduledTimes,
int? maxPlayers,
bool isOneShot)
{
var user = new PlatformUser(
PlatformKind.Telegram,
draft.OwnerId,
DisplayName: string.Empty,
ExternalUsername: null);
var group = new PlatformGroup(
PlatformKind.Telegram,
draft.ChatId,
DisplayName: string.Empty,
ExternalChannelId: null,
ExternalThreadId: draft.MessageThreadId);
return new CreateSessionCommand(
User: user,
Group: group,
Title: p.Title ?? string.Empty,
Link: string.Empty,
ScheduledTimes: scheduledTimes,
MaxPlayers: maxPlayers,
ImageReference: p.ImageFileId ?? p.ImageUrl,
System: ParseSystem(p.System),
Description: p.Description,
Format: null,
DurationMinutes: p.DurationMinutes,
IsOneShot: isOneShot);
}
private static GameSystem? ParseSystem(string? code)
{
if (string.IsNullOrWhiteSpace(code)) return null;
return Enum.TryParse<GameSystem>(code, ignoreCase: true, out var sys) ? sys : null;
}
// ── Validation ───────────────────────────────────────────────────
private static bool IsComplete(WizardPayload p, out string missing)
{
var missingFields = new List<string>();
if (string.IsNullOrWhiteSpace(p.Title)) missingFields.Add("название");
if (string.IsNullOrWhiteSpace(p.System)) missingFields.Add("система");
if (!p.DurationMinutes.HasValue) missingFields.Add("длительность");
if (p.Visibility is null) missingFields.Add("видимость");
if (p.Type == WizardCreationType.Single)
{
if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время");
if (p.Single?.MaxPlayers is null) missingFields.Add("лимит мест");
}
else
{
if (p.Pool is null || p.Pool.Slots.Count == 0) missingFields.Add("слоты");
}
missing = string.Join(", ", missingFields);
return missingFields.Count == 0;
}
// ── Payload I/O ──────────────────────────────────────────────────
private static WizardPayload LoadPayload(WizardDraft draft)
{
if (string.IsNullOrEmpty(draft.PayloadJson)) return new WizardPayload();
return JsonSerializer.Deserialize(draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
}
private static void SavePayload(WizardDraft draft, WizardPayload p)
{
draft.PayloadJson = JsonSerializer.Serialize(p, WizardPayloadJsonContext.Default.WizardPayload);
}
// ── Keyboards ────────────────────────────────────────────────────
private static IReadOnlyList<WizardAction> RetryCancelActions() => new[]
{
new WizardAction("🔁 Повторить", WizardCallbackData.Create(), WizardActionStyle.Primary),
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
};
}
@@ -1,100 +0,0 @@
using Dapper;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
public sealed record JoinSessionCommand(
Guid SessionId,
long TelegramUserId,
string DisplayName,
string? TelegramUsername,
string CallbackQueryId,
long ChatId,
int MessageId);
// DTOs for AOT compilation
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title);
public sealed class JoinSessionHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
ILogger<JoinSessionHandler> logger)
{
public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
try
{
// 1. Убеждаемся, что игрок есть в базе
var playerId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO players (telegram_id, display_name, telegram_username)
VALUES (@TgId, @Name, @Username)
ON CONFLICT (telegram_id) DO UPDATE SET display_name = EXCLUDED.display_name, telegram_username = EXCLUDED.telegram_username
RETURNING id;",
new { TgId = command.TelegramUserId, Name = command.DisplayName, Username = command.TelegramUsername },
transaction);
// 2. Добавляем в участники сессии (статус Pending, так как за 24 часа нужно будет финальное подтверждение)
var inserted = await connection.ExecuteAsync(
@"INSERT INTO session_participants (session_id, player_id, is_gm, rsvp_status)
VALUES (@SessionId, @PlayerId, false, 'Pending')
ON CONFLICT (session_id, player_id) DO NOTHING;",
new { SessionId = command.SessionId, PlayerId = playerId },
transaction);
if (inserted == 0)
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы уже записаны!", cancellationToken: ct);
return;
}
// 3. Получаем batch_id по session_id
var batchInfo = await connection.QuerySingleAsync<JoinSessionBatchDto>(
@"SELECT batch_id as BatchId, title as Title FROM sessions WHERE id = @SessionId",
new { command.SessionId }, transaction);
// Загружаем весь батч для перерисовки
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { BatchId = batchInfo.BatchId }, transaction);
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
@"SELECT sp.session_id as SessionId, p.display_name as DisplayName, p.telegram_username as TelegramUsername
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
JOIN sessions s ON sp.session_id = s.id
WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.responded_at ASC, p.created_at ASC",
new { BatchId = batchInfo.BatchId }, transaction);
await transaction.CommitAsync(ct);
// 4. Перерисовываем сообщение
var renderResult = SessionBatchRenderer.Render(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
await bot.EditMessageText(
chatId: command.ChatId,
messageId: command.MessageId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы успешно записаны!", cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка при добавлении игрока к сессии");
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Произошла ошибка при регистрации.", cancellationToken: ct);
}
}
}
@@ -0,0 +1,196 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
public sealed record PromoteWaitlistedPlayerCommand(
Guid SessionId,
long TelegramUserId,
string CallbackQueryId,
long ChatId,
int MessageId);
internal sealed record PromoteWaitlistSessionDto(string Title, Guid BatchId, int? BatchMessageId, bool CanManage, int? MaxPlayers);
internal sealed record WaitlistedParticipantDto(Guid ParticipantRowId, string DisplayName);
public sealed class PromoteWaitlistedPlayerHandler(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
ILogger<PromoteWaitlistedPlayerHandler> logger)
{
public async Task HandleAsync(PromoteWaitlistedPlayerCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var transactionCommitted = false;
try
{
var session = await connection.QuerySingleOrDefaultAsync<PromoteWaitlistSessionDto>(
"""
SELECT s.title AS Title,
s.batch_id AS BatchId,
s.batch_message_id AS BatchMessageId,
s.max_players AS MaxPlayers,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.platform = 'Telegram'
AND p.external_user_id = @ExternalUserId
) AS CanManage
FROM sessions s
WHERE s.id = @SessionId
FOR UPDATE
""",
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString() },
transaction);
if (session is null)
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
return;
}
if (!session.CanManage)
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.CallbackQueryId, "Только owner или co-GM может поднимать игроков из листа ожидания.", ct, showAlert: true);
return;
}
var activeParticipants = await connection.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*)
FROM session_participants
WHERE session_id = @SessionId
AND is_gm = false
AND registration_status = @Active
""",
new { command.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction);
var waitlistedParticipants = await connection.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*)
FROM session_participants
WHERE session_id = @SessionId
AND is_gm = false
AND registration_status = @Waitlisted
""",
new { command.SessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
transaction);
if (waitlistedParticipants == 0)
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.CallbackQueryId, "Лист ожидания пуст.", ct);
return;
}
if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants))
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.CallbackQueryId, "Нет свободных мест. Увеличьте лимит перед повышением игрока.", ct, showAlert: true);
return;
}
var promoted = await connection.QuerySingleAsync<WaitlistedParticipantDto>(
"""
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 { command.SessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
transaction);
await connection.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);
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
"""
SELECT id AS SessionId,
scheduled_at AS ScheduledAt,
status AS Status,
max_players AS MaxPlayers,
join_link AS JoinLink
FROM sessions
WHERE batch_id = @BatchId
ORDER BY scheduled_at
""",
new { session.BatchId },
transaction)).ToList();
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
p.external_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
JOIN sessions s ON sp.session_id = s.id
WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
""",
new { session.BatchId },
transaction)).ToList();
await transaction.CommitAsync(ct);
transactionCommitted = true;
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
var messageId = session.BatchMessageId ?? command.MessageId;
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(command.ChatId),
view,
TelegramPlatformIds.Message(command.ChatId, threadId: null, messageId)),
ct);
await AnswerAsync(command.CallbackQueryId, $"{promoted.DisplayName} переведен(а) в основной состав.", ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка при повышении игрока из листа ожидания для сессии {SessionId}", command.SessionId);
if (!transactionCommitted)
{
await transaction.RollbackAsync(ct);
}
var errorText = transactionCommitted
? "Игрок повышен, но не удалось обновить сообщение расписания."
: "Ошибка при обновлении листа ожидания.";
await AnswerAsync(command.CallbackQueryId, errorText, ct);
}
}
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
}
@@ -0,0 +1,142 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Telegram-side implementation of <see cref="IWizardMessenger"/>.
/// Translates the platform-neutral wizard contracts into the
/// <c>Telegram.Bot</c> SDK calls. All Telegram-specific behaviour
/// (message editing, callback ack, group lookup) lives behind the
/// interface so the wizard core stays in <c>GmRelay.Shared</c>.
/// </summary>
public sealed class TelegramWizardMessenger(
ITelegramBotClient bot,
NpgsqlDataSource dataSource) : IWizardMessenger
{
public async Task<string> EditDraftMessageAsync(
WizardDraft draft,
string text,
IReadOnlyList<WizardAction> keyboard,
CancellationToken ct)
{
if (!TryParseChatId(draft.ChatId, out var chatId))
{
throw new InvalidOperationException(
$"Wizard draft {draft.Id} has un-parseable chat id '{draft.ChatId}'.");
}
if (!TryParseMessageId(draft.DraftMessageId, out var messageId))
{
// No draft message recorded yet — fall back to sending a new one.
return await SendDraftMessageAsync(draft, text, keyboard, ct);
}
var msg = await bot.EditMessageText(
chatId: chatId,
messageId: messageId,
text: text,
replyMarkup: WizardStep.ToInlineKeyboard(keyboard),
cancellationToken: ct);
return msg.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
public async Task<string> SendDraftMessageAsync(
WizardDraft draft,
string text,
IReadOnlyList<WizardAction> keyboard,
CancellationToken ct)
{
if (!TryParseChatId(draft.ChatId, out var chatId))
{
throw new InvalidOperationException(
$"Wizard draft {draft.Id} has un-parseable chat id '{draft.ChatId}'.");
}
int? threadId = TryParseThreadId(draft.MessageThreadId, out var parsedThread)
? parsedThread
: null;
var msg = await bot.SendMessage(
chatId: chatId,
text: text,
messageThreadId: threadId,
replyMarkup: WizardStep.ToInlineKeyboard(keyboard),
cancellationToken: ct);
return msg.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
public Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct)
{
return bot.AnswerCallbackQuery(interactionId, text: text, cancellationToken: ct);
}
public async Task<IReadOnlyList<WizardClubOption>> GetOwnerClubsAsync(string ownerId, CancellationToken ct)
{
// Adjusted from the plan: this codebase models "clubs" as game_groups
// (V001 created game_groups; V026 added public_slug; no `clubs` table exists,
// and game_groups has no `club_id` FK). The picker therefore returns the
// game_groups the owner manages as a GM (via group_managers), matching
// the WizardClubOption contract (UUID id, name) used downstream.
//
// NativeAOT: Dapper.AOT 1.0.48 only generates interceptors for the
// (sql, object?) extension overload — not the (CommandDefinition) overload.
// The wizard reaches this method on the PickClub visibility step
// (issue #112 follow-up); using CommandDefinition here would fall back
// to Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit
// and throws PlatformNotSupportedException on AOT. Same root cause as
// WizardDraftRepository.GetActiveAsync in v3.9.0, same fix pattern.
const string sql = """
SELECT g.id AS ClubId,
g.name AS Name
FROM game_groups g
JOIN group_managers gm ON gm.group_id = g.id
JOIN players p ON p.id = gm.player_id
WHERE p.platform = @Platform
AND p.external_user_id = @ExternalId
GROUP BY g.id, g.name
ORDER BY g.name
""";
await using var connection = await dataSource.OpenConnectionAsync(ct);
var rows = await connection.QueryAsync<WizardClubOption>(
sql,
new { Platform = "Telegram", ExternalId = ownerId });
return rows.AsList();
}
private static bool TryParseChatId(string raw, out long chatId)
{
if (long.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out chatId))
{
return true;
}
chatId = 0;
return false;
}
private static bool TryParseMessageId(string? raw, out int messageId)
{
if (raw is not null &&
int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out messageId))
{
return true;
}
messageId = 0;
return false;
}
private static bool TryParseThreadId(string? raw, out int threadId)
{
if (raw is not null &&
int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out threadId))
{
return true;
}
threadId = 0;
return false;
}
}
@@ -0,0 +1,60 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
public sealed class WizardDraftCleanupService : BackgroundService
{
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
private readonly IWizardDraftRepository _drafts;
private readonly ILogger<WizardDraftCleanupService> _log;
public WizardDraftCleanupService(
IWizardDraftRepository drafts,
ILogger<WizardDraftCleanupService> log)
{
_drafts = drafts;
_log = log;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TickInterval);
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await RunOnceAsync(stoppingToken);
}
}
catch (OperationCanceledException)
{
// graceful shutdown
}
}
internal async Task RunOnceAsync(CancellationToken ct)
{
try
{
var deleted = await _drafts.DeleteExpiredAsync(ct);
if (deleted > 0)
{
_log.LogInformation("Wizard cleanup deleted {Count} expired drafts", deleted);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_log.LogError(ex, "Wizard cleanup tick failed");
}
}
}
@@ -0,0 +1,68 @@
using System;
using System.Globalization;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Telegram.Bot.Types;
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Converts a Telegram <see cref="Update"/> into the
/// platform-neutral <see cref="WizardInteraction"/> consumed by
/// <see cref="GameCreationWizard"/>. The mapping is the only place in
/// the bot that knows about both <c>Telegram.Bot.Types</c> and the
/// shared wizard contract, so a future Discord adapter can do the same
/// for its native event without changing the wizard core.
/// </summary>
public static class WizardInteractionMapper
{
/// <summary>
/// Returns <c>true</c> if <paramref name="update"/> carries a
/// wizard-relevant interaction (text message, photo, or
/// callback). Side-effect-free: the wizard state is not touched.
/// </summary>
public static bool TryMap(Update update, out WizardInteraction interaction)
{
interaction = default!;
if (update.CallbackQuery is { } cb && cb.From is not null)
{
interaction = new WizardInteraction(
OwnerId: cb.From.Id.ToString(CultureInfo.InvariantCulture),
Text: null,
CallbackPayload: cb.Data,
PhotoFileId: null,
PhotoUrl: null,
InteractionId: cb.Id);
return true;
}
if (update.Message is { From: not null } msg)
{
// The original Telegram wizard dispatched on
// `msg.Text is null` to identify a non-text update (photo,
// document, sticker, …) and only ran the text pipeline
// otherwise. We preserve that semantic: a message that
// carries a photo is a photo interaction even if it has a
// caption. Text is null for photos; the wizard checks
// PhotoFileId separately when Text is null.
//
// Note: `Message.MessageId` is exposed as a read-only
// property in Telegram.Bot, so the mapper cannot embed the
// numeric id in the interaction. Text interactions never
// need an ack, so the InteractionId is unused for them —
// we just emit a stable sentinel.
var hasPhoto = msg.Photo is { Length: > 0 };
var text = hasPhoto ? null : msg.Text;
var photoFileId = hasPhoto ? msg.Photo![^1].FileId : null;
interaction = new WizardInteraction(
OwnerId: msg.From!.Id.ToString(CultureInfo.InvariantCulture),
Text: text,
CallbackPayload: null,
PhotoFileId: photoFileId,
PhotoUrl: null,
InteractionId: "msg");
return true;
}
return false;
}
}
@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Linq;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Telegram-side renderer for wizard keyboards. Acts as the adapter
/// between the platform-neutral <see cref="WizardAction"/> list
/// produced by <see cref="WizardStepViewBuilder"/> and Telegram's
/// <see cref="InlineKeyboardMarkup"/>. Each <see cref="WizardAction"/>
/// becomes its own row (matching the pre-refactor Telegram layout).
/// <see cref="WizardActionStyle"/> is currently ignored by Telegram
/// because the platform has no native primary/danger/success button
/// colours.
/// </summary>
public static class WizardStep
{
public const int MaxTitleLength = WizardStepLimits.MaxTitleLength;
public const int MaxDescriptionLength = WizardStepLimits.MaxDescriptionLength;
public const int MaxSystemLength = WizardStepLimits.MaxSystemLength;
public const int MaxCapacity = WizardStepLimits.MaxCapacity;
public const int MinCapacity = WizardStepLimits.MinCapacity;
public const int MinDurationHours = WizardStepLimits.MinDurationHours;
public const int MaxDurationHours = WizardStepLimits.MaxDurationHours;
/// <summary>
/// Render the platform-neutral view into a (text, Telegram keyboard)
/// pair. Used by the wizard's surrounding code (router, create
/// handler) when it needs to send a fresh draft message or render
/// the resume/reset menu.
/// </summary>
public static (string Text, InlineKeyboardMarkup Keyboard) Render(
WizardDraft draft,
WizardPayload payload,
IReadOnlyList<WizardClubOption>? clubs = null)
{
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, clubs);
return (text, ToInlineKeyboard(actions));
}
/// <summary>
/// Convert a flat list of <see cref="WizardAction"/>s into a
/// Telegram keyboard. Each action is placed in its own row to
/// preserve the pre-refactor visual layout.
/// </summary>
public static InlineKeyboardMarkup ToInlineKeyboard(IReadOnlyList<WizardAction> actions)
{
if (actions.Count == 0)
{
return new InlineKeyboardMarkup(Array.Empty<InlineKeyboardButton[]>());
}
var rows = new InlineKeyboardButton[actions.Count][];
for (var i = 0; i < actions.Count; i++)
{
rows[i] = new[]
{
InlineKeyboardButton.WithCallbackData(actions[i].Label, actions[i].Payload),
};
}
return new InlineKeyboardMarkup(rows);
}
}
@@ -1,76 +1,25 @@
using System.Text;
using Dapper;
using Npgsql;
using Telegram.Bot;
using GmRelay.Shared.Platform;
using Telegram.Bot.Types;
namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
public sealed class ExportCalendarHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient botClient)
GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarHandler sharedHandler)
{
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
public Task HandleAsync(Message message, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var command = new GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarCommand(
new PlatformGroup(
PlatformKind.Telegram,
message.Chat.Id.ToString(),
message.Chat.Title ?? "Private Chat",
message.MessageThreadId?.ToString()),
new PlatformUser(
PlatformKind.Telegram,
message.From?.Id.ToString() ?? string.Empty,
message.From?.FirstName ?? string.Empty,
message.From?.Username));
var sessions = await connection.QueryAsync<CalendarSessionDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
WHERE g.telegram_chat_id = @ChatId
AND s.status = 'Planned'
AND s.scheduled_at > NOW()
ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id });
var sessionsList = sessions.ToList();
if (sessionsList.Count == 0)
{
await botClient.SendMessage(
chatId: message.Chat.Id,
text: "📭 У этой группы нет запланированных сессий для экспорта.",
cancellationToken: cancellationToken);
return;
}
var sb = new StringBuilder();
sb.AppendLine("BEGIN:VCALENDAR");
sb.AppendLine("VERSION:2.0");
sb.AppendLine("PRODID:-//GM-Relay//TTRPG Schedule//EN");
foreach (var s in sessionsList)
{
var dtStart = s.ScheduledAt.ToString("yyyyMMddTHHmmssZ");
var dtEnd = s.ScheduledAt.AddHours(4).ToString("yyyyMMddTHHmmssZ");
sb.AppendLine("BEGIN:VEVENT");
sb.AppendLine($"UID:{s.Id}@gmrelay");
sb.AppendLine($"DTSTAMP:{DateTime.UtcNow:yyyyMMddTHHmmssZ}");
sb.AppendLine($"DTSTART:{dtStart}");
sb.AppendLine($"DTEND:{dtEnd}");
sb.AppendLine($"SUMMARY:{s.Title}");
// Escape special chars according to iCal standards (RFC 5545) -- simple escaping for summary
// In a fuller implementation we'd escape \r\n, commas, etc. But titles are mostly plain text.
sb.AppendLine("END:VEVENT");
}
sb.AppendLine("END:VCALENDAR");
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
using var stream = new MemoryStream(bytes);
var inputFile = InputFile.FromStream(stream, "schedule.ics");
await botClient.SendDocument(
chatId: message.Chat.Id,
document: inputFile,
caption: "📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
messageThreadId: message.MessageThreadId,
cancellationToken: cancellationToken);
return sharedHandler.HandleAsync(command, cancellationToken);
}
}
@@ -1,7 +1,5 @@
using Dapper;
using Npgsql;
using Telegram.Bot;
using GmRelay.Shared.Domain;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Features.Sessions.ListSessions;
@@ -12,108 +10,88 @@ public sealed record DeleteSessionCommand(
long ChatId,
int MessageId);
internal sealed record DeleteSessionInfoDto(string Title, Guid BatchId, long GmId, int? ThreadId);
public sealed class DeleteSessionHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionHandler sharedHandler,
GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler listSessionsHandler,
IPlatformMessenger messenger,
ILogger<DeleteSessionHandler> logger)
{
public async Task HandleAsync(DeleteSessionCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var platformUser = new PlatformUser(
PlatformKind.Telegram,
command.TelegramUserId.ToString(),
string.Empty,
null);
// 1. Fetch session and verify GM
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
@"SELECT s.title as Title, s.batch_id as BatchId, s.thread_id as ThreadId, g.gm_telegram_id as GmId
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
WHERE s.id = @SessionId",
new { command.SessionId }, transaction);
var platformGroup = new PlatformGroup(
PlatformKind.Telegram,
command.ChatId.ToString(),
string.Empty);
if (session == null)
var scheduleMessage = TelegramPlatformIds.Message(command.ChatId, null, command.MessageId);
var sharedCommand = new GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionCommand(
command.SessionId,
platformUser,
platformGroup,
scheduleMessage);
var result = await sharedHandler.HandleAsync(sharedCommand, ct);
if (!result.Success)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!, result.ReplyText!.Contains("owner")),
ct);
return;
}
if (session.GmId != command.TelegramUserId)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может удалять сессию.", showAlert: true, cancellationToken: ct);
return;
}
// 2. Delete session
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
// 3. Check if any sessions are left in the batch
var remainingInBatch = await connection.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM sessions WHERE batch_id = @BatchId",
new { BatchId = session.BatchId }, transaction);
await transaction.CommitAsync(ct);
// 4. If no sessions left and we have a forum topic, delete the topic
if (remainingInBatch == 0 && session.ThreadId.HasValue)
// 4. If no sessions are left in a bot-owned forum topic, delete the topic.
if (result.ThreadId.HasValue &&
TelegramTopicRouting.ShouldDeleteForumTopic(result.TopicCreatedByBot, result.RemainingInTopic))
{
try
{
await bot.DeleteForumTopic(command.ChatId, session.ThreadId.Value, cancellationToken: ct);
logger.LogInformation("Deleted forum topic {ThreadId} for batch {BatchId} as no sessions remained.", session.ThreadId.Value, session.BatchId);
await messenger.DeleteThreadAsync(
new PlatformGroup(PlatformKind.Telegram, command.ChatId.ToString(), string.Empty, null, result.ThreadId.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)),
ct);
logger.LogInformation("Deleted forum topic {ThreadId} for batch {BatchId} as no sessions remained.", result.ThreadId.Value, result.GroupId);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to delete forum topic {ThreadId}", session.ThreadId.Value);
logger.LogWarning(ex, "Failed to delete forum topic {ThreadId}", result.ThreadId.Value);
}
}
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия удалена!", cancellationToken: ct);
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!),
ct);
// 5. Update the /listsessions message (we delete the message or edit it to remove the button)
// A simple way is to re-render the list:
await using var readConnection = await dataSource.OpenConnectionAsync(ct);
var sessions = await readConnection.QueryAsync<SessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false) as PlayerCount,
g.gm_telegram_id as GmId
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.telegram_chat_id = @ChatId AND s.status != 'Cancelled' AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, g.gm_telegram_id
ORDER BY s.scheduled_at ASC",
new { ChatId = command.ChatId });
// 5. Update the /listsessions message
var listCommand = new GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsCommand(platformGroup, platformUser);
var listResult = await listSessionsHandler.HandleAsync(listCommand, ct);
var sessionsList = sessions.ToList();
if (sessionsList.Count == 0)
if (listResult.Sessions.Count == 0)
{
try { await bot.EditMessageText(command.ChatId, command.MessageId, "📭 В этой группе нет предстоящих игр.", cancellationToken: ct); } catch {}
try
{
await messenger.UpdateGroupMessageAsync(
scheduleMessage,
"📭 В этой группе нет предстоящих игр.",
[],
ct);
}
catch { }
return;
}
var text = "📅 <b>Ближайшие игры:</b>\n\n";
foreach (var s in sessionsList)
{
text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Участников: {s.PlayerCount})\n";
}
var isGm = command.TelegramUserId == sessionsList.First().GmId;
var keyboard = isGm
? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup(
sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") }))
: null;
var text = SessionListMessageRenderer.RenderText(listResult.Sessions);
var actions = listResult.CanManage ? SessionListMessageRenderer.RenderActions(listResult.Sessions) : [];
try
{
await bot.EditMessageText(
command.ChatId,
command.MessageId,
text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: ct);
await messenger.UpdateGroupMessageAsync(scheduleMessage, text, actions, ct);
}
catch (Exception ex)
{
@@ -1,61 +1,37 @@
using Dapper;
using GmRelay.Shared.Domain;
using Npgsql;
using Telegram.Bot;
using GmRelay.Shared.Platform;
using Telegram.Bot.Types;
namespace GmRelay.Bot.Features.Sessions.ListSessions;
internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int PlayerCount, long GmId);
public sealed class ListSessionsHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient botClient)
GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler sharedHandler,
IPlatformMessenger messenger)
{
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var command = new GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsCommand(
new PlatformGroup(
PlatformKind.Telegram,
message.Chat.Id.ToString(),
message.Chat.Title ?? "Private Chat",
message.MessageThreadId?.ToString()),
new PlatformUser(
PlatformKind.Telegram,
message.From?.Id.ToString() ?? string.Empty,
message.From?.FirstName ?? string.Empty,
message.From?.Username));
var sessions = await connection.QueryAsync<SessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false) as PlayerCount,
g.gm_telegram_id as GmId
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.telegram_chat_id = @ChatId AND s.status != 'Cancelled' AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, g.gm_telegram_id
ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id });
var result = await sharedHandler.HandleAsync(command, cancellationToken);
var sessionsList = sessions.ToList();
if (sessionsList.Count == 0)
if (result.Sessions.Count == 0)
{
await botClient.SendMessage(
chatId: message.Chat.Id,
text: "📭 В этой группе нет предстоящих игр.",
cancellationToken: cancellationToken);
await messenger.SendGroupMessageAsync(command.Group, "📭 В этой группе нет предстоящих игр.", cancellationToken);
return;
}
var text = "📅 <b>Ближайшие игры:</b>\n\n";
foreach (var s in sessionsList)
{
text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Участников: {s.PlayerCount})\n";
}
var text = SessionListMessageRenderer.RenderText(result.Sessions);
var actions = result.CanManage ? SessionListMessageRenderer.RenderActions(result.Sessions) : [];
var isGm = message.From?.Id == sessionsList.First().GmId;
var keyboard = isGm
? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup(
sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") }))
: null;
await botClient.SendMessage(
chatId: message.Chat.Id,
text: text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken);
await messenger.SendGroupMessageAsync(command.Group, text, actions, cancellationToken);
}
}
@@ -0,0 +1,63 @@
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Features.Sessions.ListSessions;
namespace GmRelay.Bot.Features.Sessions.ListSessions;
internal static class SessionListMessageRenderer
{
public static string RenderText(IReadOnlyList<SessionListItemDto> sessions)
{
var text = "📅 <b>Ближайшие игры:</b>\n\n";
foreach (var session in sessions)
{
var seats = session.MaxPlayers.HasValue
? $"{session.PlayerCount}/{session.MaxPlayers.Value}"
: session.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
var waitlist = session.WaitlistCount > 0 ? $", ожидание: {session.WaitlistCount}" : string.Empty;
text += $"🔹 <b>{session.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(session.Title)} (Места: {seats}{waitlist})\n";
}
return text;
}
public static IReadOnlyList<PlatformMessageAction> RenderActions(IReadOnlyList<SessionListItemDto> sessions)
{
if (sessions.Count == 0 || !sessions.First().CanManage)
{
return [];
}
var actions = new List<PlatformMessageAction>();
foreach (var session in sessions)
{
var dateTitle = session.ScheduledAt.FormatMoscowShort();
actions.Add(new PlatformMessageAction(
$"cancel_session:{session.Id}",
$"❌ {dateTitle}",
$"cancel_session:{session.Id}"));
actions.Add(new PlatformMessageAction(
$"reschedule_session:{session.Id}",
$"⏰ {dateTitle}",
$"reschedule_session:{session.Id}"));
if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount))
{
actions.Add(new PlatformMessageAction(
$"promote_waitlist:{session.Id}",
$"⬆️ Из ожидания {dateTitle}",
$"promote_waitlist:{session.Id}"));
}
actions.Add(new PlatformMessageAction(
$"delete_session:{session.Id}",
$"🗑 Удалить {dateTitle}",
$"delete_session:{session.Id}"));
}
return actions;
}
}
@@ -1,257 +1,269 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
// ── DTOs ─────────────────────────────────────────────────────────────
internal sealed record AwaitingProposalDto(
Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt,
Guid BatchId, int? BatchMessageId, long TelegramChatId);
internal sealed record VoteParticipantDto(Guid PlayerId, string DisplayName, string? TelegramUsername);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary>
/// Handles text input from the GM who has an AwaitingTime proposal.
/// Parses the new time, creates a voting message, and tags all participants.
/// If no participants are registered, reschedules immediately.
/// Telegram adapter for reschedule time input.
/// Delegates core logic to the shared handler, then performs Telegram-specific
/// message sending, DM notifications, vote_message_id storage, and cleanup.
/// </summary>
public sealed class HandleRescheduleTimeInputHandler(
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler sharedHandler,
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
DirectSessionNotificationSender directSender,
ILogger<HandleRescheduleTimeInputHandler> logger)
{
/// <summary>
/// Attempts to handle a text message as reschedule time input.
/// Returns true if it was handled (i.e. user had an AwaitingTime proposal).
/// </summary>
public async Task<bool> TryHandleAsync(Message message, CancellationToken ct)
{
if (message.From is null || string.IsNullOrWhiteSpace(message.Text))
return false;
var gmTelegramId = message.From.Id;
var chatId = message.Chat.Id;
var text = message.Text.Trim();
var command = new GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputCommand(
new PlatformUser(
PlatformKind.Telegram,
message.From.Id.ToString(),
message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? "" : $" {message.From.LastName}"),
message.From.Username),
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title),
message.Text.Trim());
await using var connection = await dataSource.OpenConnectionAsync(ct);
// 1. Check if this GM has an AwaitingTime proposal in this chat
var proposal = await connection.QuerySingleOrDefaultAsync<AwaitingProposalDto>(
"""
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId
FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id
JOIN game_groups g ON g.id = s.group_id
WHERE rp.proposed_by = @GmId
AND rp.status = 'AwaitingTime'
AND g.telegram_chat_id = @ChatId
ORDER BY rp.created_at DESC
LIMIT 1
""",
new { GmId = gmTelegramId, ChatId = chatId });
if (proposal is null)
var result = await sharedHandler.HandleAsync(command, ct);
if (!result.Handled)
return false;
// 2. Parse the new time
if (!MoscowTime.TryParseMoscow(text, out var newTime))
if (!string.IsNullOrEmpty(result.ReplyText) && !result.IsRescheduledImmediately)
{
await bot.SendMessage(
chatId: chatId,
text: "⚠️ Не удалось распознать время. Используйте формат: <code>ДД.ММ.ГГГГ ЧЧ:ММ</code>\nНапример: <code>25.04.2026 19:30</code>",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
await messenger.SendGroupMessageAsync(
command.Group,
$"""⚠️ {result.ReplyText}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>""",
ct);
return true;
}
if (newTime <= DateTimeOffset.UtcNow)
if (result.IsRescheduledImmediately)
{
await bot.SendMessage(
chatId: chatId,
text: "⚠️ Новое время должно быть в будущем. Попробуйте снова.",
cancellationToken: ct);
if (result.UpdatedView is not null && result.BatchMessageId.HasValue)
{
await TryUpdateBatchMessage(
command.Group,
result.UpdatedView,
TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, result.BatchMessageId.Value),
ct);
}
await messenger.SendGroupMessageAsync(command.Group, result.ReplyText!, ct);
await TryDeleteMessage(message.Chat.Id, message.MessageId, ct);
return true;
}
// 3. Load participants (non-GM) signed up for this session
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false
""",
new { proposal.SessionId })).ToList();
// Voting mode
var voteText = BuildVotingMessage(
result.Title!,
result.CurrentScheduledAt,
result.VotingDeadlineAt!.Value,
result.Options,
result.Participants,
[]);
// 4. If no participants — reschedule immediately
if (participants.Count == 0)
{
await RescheduleImmediately(connection, proposal, newTime, chatId, ct);
await TryDeleteMessage(chatId, message.MessageId, ct);
return true;
}
// 5. Create voting message
await using var transaction = await connection.BeginTransactionAsync(ct);
// Update proposal with proposed time and Voting status
await connection.ExecuteAsync(
"""
UPDATE reschedule_proposals
SET proposed_at = @ProposedAt, status = 'Voting', vote_chat_id = @ChatId
WHERE id = @Id
""",
new { ProposedAt = newTime, ChatId = chatId, Id = proposal.Id },
transaction);
await transaction.CommitAsync(ct);
// Build voting message text
var voteText = BuildVotingMessage(proposal.Title, proposal.CurrentScheduledAt, newTime, participants, []);
var keyboard = new InlineKeyboardMarkup([
[
InlineKeyboardButton.WithCallbackData("✅ Согласен", $"reschedule_vote:yes:{proposal.Id}"),
InlineKeyboardButton.WithCallbackData("❌ Против", $"reschedule_vote:no:{proposal.Id}")
]
]);
var keyboard = BuildVotingKeyboard(result.Options);
var voteMsg = await bot.SendMessage(
chatId: chatId,
chatId: message.Chat.Id,
messageThreadId: message.MessageThreadId,
text: voteText,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: ct);
// Store vote message ID
var mode = await GetNotificationModeAsync(result.ProposalId!.Value, ct);
if (mode.ShouldSendDirectMessages())
{
var optionsText = string.Join(
"\n",
result.Options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
var directText = $"""
🔄 <b>Голосование за перенос сессии</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
📅 Текущее время: <b>{result.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
🗳 Варианты:
{optionsText}
⏳ Дедлайн: <b>{result.VotingDeadlineAt.Value.FormatMoscow()}</b> (МСК)
Проголосуйте кнопкой в групповом сообщении.
""";
await directSender.SendAsync(
result.Participants.Select(p => new DirectNotificationRecipient(
p.TelegramId,
p.DisplayName)),
directText,
"reschedule-vote",
result.ProposalId.Value,
ct);
}
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id",
new { MsgId = voteMsg.MessageId, Id = proposal.Id });
new { MsgId = voteMsg.MessageId, Id = result.ProposalId.Value });
logger.LogInformation("Reschedule voting started for session {SessionId}, proposal {ProposalId}", proposal.SessionId, proposal.Id);
// Delete GM's time input message
await TryDeleteMessage(chatId, message.MessageId, ct);
logger.LogInformation(
"Reschedule voting started for session {SessionId}, proposal {ProposalId}, options {OptionCount}, deadline {Deadline}",
result.ProposalId.Value,
result.ProposalId.Value,
result.Options.Count,
result.VotingDeadlineAt.Value);
await TryDeleteMessage(message.Chat.Id, message.MessageId, ct);
return true;
}
private async Task RescheduleImmediately(
NpgsqlConnection connection, AwaitingProposalDto proposal,
DateTimeOffset newTime, long chatId, CancellationToken ct)
private async Task<SessionNotificationMode> GetNotificationModeAsync(Guid proposalId, CancellationToken ct)
{
await using var transaction = await connection.BeginTransactionAsync(ct);
await connection.ExecuteAsync(
await using var connection = await dataSource.OpenConnectionAsync(ct);
var raw = await connection.QuerySingleOrDefaultAsync<string?>(
"""
UPDATE sessions SET scheduled_at = @NewTime, status = 'Planned', updated_at = now()
WHERE id = @SessionId
SELECT s.notification_mode
FROM sessions s
JOIN reschedule_proposals rp ON rp.session_id = s.id
WHERE rp.id = @Id
""",
new { NewTime = newTime, proposal.SessionId },
transaction);
new { Id = proposalId });
return SessionNotificationModeExtensions.FromDatabaseValue(raw ?? string.Empty);
}
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET proposed_at = @NewTime, status = 'Approved' WHERE id = @Id",
new { NewTime = newTime, Id = proposal.Id },
transaction);
await transaction.CommitAsync(ct);
await bot.SendMessage(
chatId: chatId,
text: $"✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)\n\n<i>Участников нет — голосование не требуется.</i>",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
// Re-render batch message with updated time
await TryUpdateBatchMessage(proposal, ct);
logger.LogInformation("Session {SessionId} rescheduled immediately (no participants)", proposal.SessionId);
private async Task TryUpdateBatchMessage(
PlatformGroup group,
SessionBatchViewModel view,
PlatformMessageRef scheduleMessage,
CancellationToken ct)
{
try
{
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(group, view, scheduleMessage),
ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update batch message after immediate reschedule");
}
}
internal static string BuildVotingMessage(
string title, DateTime currentTime, DateTimeOffset newTime,
string title,
DateTime currentTime,
DateTimeOffset deadline,
IReadOnlyList<RescheduleOptionDto> options,
IReadOnlyList<VoteParticipantDto> participants,
IReadOnlyCollection<Guid> approvedPlayerIds)
IReadOnlyList<RescheduleOptionVoteDto> votes)
{
var votesByOption = votes
.GroupBy(v => v.OptionId)
.ToDictionary(g => g.Key, g => g.ToList());
var votedPlayerIds = votes.Select(v => v.PlayerId).ToHashSet();
var pendingParticipants = participants
.Where(p => !votedPlayerIds.Contains(p.PlayerId))
.Select(FormatParticipantName)
.ToList();
var lines = new List<string>
{
$"🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>",
$"""🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>""",
"",
$"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)",
$"📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)",
$"⏳ Дедлайн: <b>{deadline.FormatMoscow()}</b> (МСК)",
"",
"Для переноса нужно согласие всех участников:"
"Выберите один из вариантов:"
};
foreach (var p in participants)
foreach (var option in options.OrderBy(x => x.DisplayOrder))
{
var name = p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName;
var icon = approvedPlayerIds.Contains(p.PlayerId) ? "✅" : "⏳";
lines.Add($" {icon} {name}");
var optionVotes = votesByOption.GetValueOrDefault(option.OptionId, []);
lines.Add(
$"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК) — {FormatVoteCount(optionVotes.Count)}");
if (optionVotes.Count > 0)
{
lines.Add($" {string.Join(", ", optionVotes.Select(FormatParticipantName))}");
}
}
if (pendingParticipants.Count > 0)
{
lines.Add("");
lines.Add($"Не проголосовали: {string.Join(", ", pendingParticipants)}");
}
lines.Add("");
lines.Add($"Голоса: {approvedPlayerIds.Count}/{participants.Count}");
lines.Add($"Голосов: {votedPlayerIds.Count}/{participants.Count}");
lines.Add("Правило: побеждает вариант с большинством голосов к дедлайну; при ничьей перенос не применяется.");
return string.Join("\n", lines);
}
private async Task TryUpdateBatchMessage(AwaitingProposalDto proposal, CancellationToken ct)
internal static InlineKeyboardMarkup BuildVotingKeyboard(IReadOnlyList<RescheduleOptionDto> options)
{
try
{
await using var conn = await dataSource.OpenConnectionAsync(ct);
var batchSessions = (await conn.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { proposal.BatchId })).ToList();
var batchParticipants = (await conn.QueryAsync<ParticipantBatchDto>(
"""
SELECT sp.session_id AS SessionId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
JOIN sessions s ON sp.session_id = s.id
WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.responded_at ASC, p.created_at ASC
""",
new { proposal.BatchId })).ToList();
if (proposal.BatchMessageId.HasValue)
{
var renderResult = SessionBatchRenderer.Render(
proposal.Title, batchSessions, batchParticipants);
await bot.EditMessageText(
chatId: proposal.TelegramChatId,
messageId: proposal.BatchMessageId.Value,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
}
else
{
logger.LogWarning("No batch_message_id stored for session {SessionId}, cannot edit batch message in-place", proposal.SessionId);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update batch message after immediate reschedule for session {SessionId}", proposal.SessionId);
}
return new InlineKeyboardMarkup(
options
.OrderBy(option => option.DisplayOrder)
.Select(option => new[]
{
InlineKeyboardButton.WithCallbackData(
$"{option.DisplayOrder}. {FormatButtonTime(option.ProposedAt)}",
$"reschedule_vote:{option.OptionId}")
}));
}
internal static string FormatParticipantName(VoteParticipantDto participant)
{
return participant.TelegramUsername is { Length: > 0 } username
? $"@{System.Net.WebUtility.HtmlEncode(username)}"
: System.Net.WebUtility.HtmlEncode(participant.DisplayName);
}
internal static string FormatParticipantName(RescheduleOptionVoteDto vote)
{
return vote.TelegramUsername is { Length: > 0 } username
? $"@{System.Net.WebUtility.HtmlEncode(username)}"
: System.Net.WebUtility.HtmlEncode(vote.DisplayName);
}
private static string FormatVoteCount(int count)
{
var modulo100 = count % 100;
var modulo10 = count % 10;
var word = modulo100 is >= 11 and <= 14
? "голосов"
: modulo10 switch
{
1 => "голос",
>= 2 and <= 4 => "голоса",
_ => "голосов"
};
return $"{count} {word}";
}
private static string FormatButtonTime(DateTimeOffset utc)
=> utc.ToOffset(TimeSpan.FromHours(3)).ToString(
"dd.MM HH:mm",
System.Globalization.CultureInfo.InvariantCulture);
private async Task TryDeleteMessage(long chatId, int messageId, CancellationToken ct)
{
try
@@ -1,314 +1,80 @@
using Dapper;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using GmRelay.Bot.Features.Sessions.CreateSession;
using Npgsql;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using Telegram.Bot;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
// ── Command ──────────────────────────────────────────────────────────
public sealed record HandleRescheduleVoteCommand(
Guid ProposalId,
string Vote, // "yes" or "no"
Guid OptionId,
long TelegramUserId,
string CallbackQueryId,
long ChatId,
int MessageId);
// ── DTOs ─────────────────────────────────────────────────────────────
internal sealed record VoteProposalDto(
Guid Id,
Guid SessionId,
DateTime ProposedAt,
string Title,
DateTime CurrentScheduledAt,
Guid BatchId,
string SessionStatus,
long TelegramChatId,
int? ConfirmationMessageId,
int? BatchMessageId);
internal sealed record VoteCountDto(int Total, int Approved);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary>
/// Handles "✅ Согласен" / "❌ Против" votes on a reschedule proposal.
///
/// If anyone votes no → proposal rejected, old time stays.
/// If all vote yes → session time updated, batch message re-rendered,
/// session status reset to Planned so confirmation triggers work correctly.
/// </summary>
public sealed class HandleRescheduleVoteHandler(
NpgsqlDataSource dataSource,
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler sharedHandler,
ITelegramBotClient bot,
IPlatformMessenger messenger,
ILogger<HandleRescheduleVoteHandler> logger)
{
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var platformUser = new PlatformUser(
PlatformKind.Telegram,
command.TelegramUserId.ToString(),
string.Empty,
null);
// 1. Load proposal + session info
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
"""
SELECT rp.id AS Id, rp.session_id AS SessionId, rp.proposed_at AS ProposedAt,
s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
s.batch_id AS BatchId, s.status AS SessionStatus,
s.confirmation_message_id AS ConfirmationMessageId,
s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId
FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id
JOIN game_groups g ON g.id = s.group_id
WHERE rp.id = @ProposalId AND rp.status = 'Voting'
""",
new { command.ProposalId },
transaction);
var platformGroup = new PlatformGroup(
PlatformKind.Telegram,
command.ChatId.ToString(),
string.Empty);
if (proposal is null)
var sharedCommand = new GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteCommand(
command.OptionId,
platformUser,
platformGroup,
command.CallbackQueryId,
TelegramPlatformIds.Message(command.ChatId, null, command.MessageId));
var result = await sharedHandler.HandleAsync(sharedCommand, ct);
if (!result.Success)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId,
"Голосование уже завершено или не найдено.", cancellationToken: ct);
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!, result.ReplyText!.Contains("дедлайн")),
ct);
return;
}
// 2. Verify voter is a participant of this session
var playerId = await connection.ExecuteScalarAsync<Guid?>(
"""
SELECT p.id
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.telegram_id = @TelegramUserId
AND sp.is_gm = false
""",
new { proposal.SessionId, command.TelegramUserId },
transaction);
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
result.Title!,
result.CurrentScheduledAt,
result.VotingDeadlineAt,
result.Options,
result.Participants,
result.Votes);
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(result.Options);
if (playerId is null)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId,
"Вы не являетесь участником этой сессии.", cancellationToken: ct);
return;
}
// 3. Record vote (upsert)
var inserted = await connection.ExecuteAsync(
"""
INSERT INTO reschedule_votes (proposal_id, player_id, vote)
VALUES (@ProposalId, @PlayerId, @Vote)
ON CONFLICT (proposal_id, player_id) DO UPDATE SET vote = EXCLUDED.vote, voted_at = now()
""",
new { command.ProposalId, PlayerId = playerId.Value, command.Vote },
transaction);
// 4. Handle "no" vote — immediately reject
if (command.Vote == "no")
{
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @Id",
new { Id = command.ProposalId },
transaction);
await transaction.CommitAsync(ct);
// Get voter's name
var voterName = await connection.QuerySingleOrDefaultAsync<string>(
"SELECT display_name FROM players WHERE telegram_id = @TgId",
new { TgId = command.TelegramUserId });
// Update voting message — show rejection
try
{
await bot.EditMessageText(
chatId: command.ChatId,
messageId: command.MessageId,
text: $"❌ <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}» отклонён!</b>\n\n{voterName ?? "Участник"} проголосовал(а) против. Время сессии остаётся прежним:\n📅 <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update vote message after rejection");
}
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы проголосовали против переноса.", cancellationToken: ct);
logger.LogInformation("Reschedule proposal {ProposalId} rejected by player {PlayerId}", command.ProposalId, playerId);
return;
}
// 5. Handle "yes" vote — check if all approved
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false
""",
new { proposal.SessionId },
transaction)).ToList();
var approvedPlayerIds = (await connection.QueryAsync<Guid>(
"""
SELECT player_id FROM reschedule_votes
WHERE proposal_id = @ProposalId AND vote = 'yes'
""",
new { command.ProposalId },
transaction)).ToHashSet();
var allApproved = approvedPlayerIds.Count == participants.Count;
if (allApproved)
{
// 6. All approved — reschedule!
var newTime = new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero); // ProposedAt is stored in UTC
// Update session time and reset status to Planned for fresh notification cycle
await connection.ExecuteAsync(
"""
UPDATE sessions
SET scheduled_at = @NewTime,
status = 'Planned',
confirmation_message_id = NULL,
link_message_id = NULL,
updated_at = now()
WHERE id = @SessionId
""",
new { NewTime = newTime, proposal.SessionId },
transaction);
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET status = 'Approved' WHERE id = @Id",
new { Id = command.ProposalId },
transaction);
// Reset all participant RSVP to Pending for the new confirmation cycle
await connection.ExecuteAsync(
"""
UPDATE session_participants
SET rsvp_status = 'Pending', responded_at = NULL
WHERE session_id = @SessionId AND is_gm = false
""",
new { proposal.SessionId },
transaction);
await transaction.CommitAsync(ct);
// Update voting message — show approval
try
{
await bot.EditMessageText(
chatId: command.ChatId,
messageId: command.MessageId,
text: $"✅ <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}» одобрен!</b>\n\nВсе участники согласились.\n📅 Новое время: <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК)\n\n<i>Уведомления будут приходить согласно новому расписанию.</i>",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update vote message after approval");
}
// Re-render batch message
await TryUpdateBatchMessage(proposal, ct);
logger.LogInformation("Session {SessionId} rescheduled to {NewTime} (proposal {ProposalId})",
proposal.SessionId, newTime, command.ProposalId);
}
else
{
// Not all voted yet — update the voting message to show progress
await transaction.CommitAsync(ct);
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
proposal.Title, proposal.CurrentScheduledAt,
new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero),
participants, approvedPlayerIds);
var keyboard = new InlineKeyboardMarkup([
[
InlineKeyboardButton.WithCallbackData("✅ Согласен", $"reschedule_vote:yes:{command.ProposalId}"),
InlineKeyboardButton.WithCallbackData("❌ Против", $"reschedule_vote:no:{command.ProposalId}")
]
]);
try
{
await bot.EditMessageText(
chatId: command.ChatId,
messageId: command.MessageId,
text: voteText,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update vote message with progress");
}
}
await bot.AnswerCallbackQuery(command.CallbackQueryId,
allApproved ? "Вы подтвердили перенос! Все согласны — время обновлено." : "Вы подтвердили перенос!",
cancellationToken: ct);
}
/// <summary>
/// Re-renders the batch schedule message to reflect the updated session time.
/// If batch_message_id is stored, edits the original message. Otherwise sends a notification.
/// </summary>
private async Task TryUpdateBatchMessage(VoteProposalDto proposal, CancellationToken ct)
{
try
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { proposal.BatchId })).ToList();
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
"""
SELECT sp.session_id AS SessionId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
JOIN sessions s ON sp.session_id = s.id
WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.responded_at ASC, p.created_at ASC
""",
new { proposal.BatchId })).ToList();
if (proposal.BatchMessageId.HasValue)
{
// Edit the original batch schedule message in-place
var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants);
await bot.EditMessageText(
chatId: proposal.TelegramChatId,
messageId: proposal.BatchMessageId.Value,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
}
else
{
// Fallback for sessions created before V005 migration (no batch_message_id)
await bot.SendMessage(
chatId: proposal.TelegramChatId,
text: $"📢 Расписание обновлено! Сессия «{proposal.Title}» перенесена на <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК).",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
}
await bot.EditMessageText(
chatId: command.ChatId,
messageId: command.MessageId,
text: voteText,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update batch message for proposal {ProposalId}", proposal.Id);
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", result.ProposalId);
}
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!),
ct);
}
}
@@ -1,6 +1,8 @@
using Dapper;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Npgsql;
using Telegram.Bot;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
@@ -11,48 +13,55 @@ public sealed record InitiateRescheduleCommand(
long TelegramUserId,
string CallbackQueryId,
long ChatId,
int? MessageThreadId,
int MessageId);
// ── DTOs ─────────────────────────────────────────────────────────────
internal sealed record RescheduleSessionInfoDto(string Title, long GmId);
internal sealed record RescheduleSessionInfoDto(string Title, bool CanManage);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary>
/// Handles the "⏰ Перенести" button press from the batch message.
/// Creates a reschedule proposal in AwaitingTime status and prompts
/// the GM to enter the new time via a regular text message.
/// the GM to enter 2-3 new time options and a voting deadline.
/// </summary>
public sealed class InitiateRescheduleHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
ILogger<InitiateRescheduleHandler> logger)
{
public async Task HandleAsync(InitiateRescheduleCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
// 1. Verify GM ownership
// 1. Verify group management access.
var session = await connection.QuerySingleOrDefaultAsync<RescheduleSessionInfoDto>(
"""
SELECT s.title AS Title, g.gm_telegram_id AS GmId
SELECT s.title AS Title,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.platform = 'Telegram'
AND p.external_user_id = @ExternalUserId
) AS CanManage
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
WHERE s.id = @SessionId AND s.status != 'Cancelled'
WHERE s.id = @SessionId AND s.status != @Cancelled
""",
new { command.SessionId });
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString(), Cancelled = SessionStatus.Cancelled });
if (session is null)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
return;
}
if (session.GmId != command.TelegramUserId)
if (!session.CanManage)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId,
"Только Мастер Игры (GM) может переносить сессию.", showAlert: true, cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Только owner или co-GM может переносить сессию.", ct, showAlert: true);
return;
}
@@ -68,29 +77,43 @@ public sealed class InitiateRescheduleHandler(
if (hasActive)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId,
"Уже есть активный запрос на перенос этой сессии.", showAlert: true, cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Уже есть активный запрос на перенос этой сессии.", ct, showAlert: true);
return;
}
// 3. Create proposal in AwaitingTime status
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_proposals (session_id, proposed_by, status)
VALUES (@SessionId, @GmId, 'AwaitingTime')
INSERT INTO reschedule_proposals (session_id, proposed_by_external_user_id, source_platform, status)
VALUES (@SessionId, @ProposedBy, 'Telegram', 'AwaitingTime')
""",
new { command.SessionId, GmId = command.TelegramUserId });
new { command.SessionId, ProposedBy = command.TelegramUserId.ToString() });
logger.LogInformation("Reschedule initiated for session {SessionId} by GM {GmId}", command.SessionId, command.TelegramUserId);
// 4. Prompt GM in chat
await bot.AnswerCallbackQuery(command.CallbackQueryId,
"Введите новое время в чат (формат: ДД.ММ.ГГГГ ЧЧ:ММ)", cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Введите 2-3 варианта времени и дедлайн голосования.", ct);
await bot.SendMessage(
chatId: command.ChatId,
text: $"⏰ Укажите новое время для сессии «{session.Title}» в формате:\n<code>ДД.ММ.ГГГГ ЧЧ:ММ</code>\n\nНапример: <code>25.04.2026 19:30</code>",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
var prompt = string.Join(
"\n",
new[]
{
$"⏰ Укажите 2-3 варианта времени для сессии «{session.Title}» и дедлайн голосования.",
"",
"Формат:",
"<code>25.04.2026 19:30",
"26.04.2026 18:00",
"Дедлайн: 25.04.2026 12:00</code>",
"",
"Дедлайн должен быть в будущем и раньше первого предложенного времени."
});
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId),
prompt,
ct);
}
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
}
@@ -0,0 +1,225 @@
using Dapper;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Notifications;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
internal sealed record TelegramProposalFieldsDto(
int? VoteMessageId,
int? BatchMessageId,
long TelegramChatId,
int? ThreadId);
public sealed class RescheduleVotingDeadlineService(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
PlatformDirectNotificationSender directSender,
RescheduleVotingFinalizer finalizer,
ILogger<RescheduleVotingDeadlineService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await ProcessDueProposals(stoppingToken);
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await ProcessDueProposals(stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
}
}
private async Task ProcessDueProposals(CancellationToken ct)
{
try
{
var proposalIds = await finalizer.GetDueProposalIdsAsync("Telegram", ct);
foreach (var proposalId in proposalIds)
{
await FinalizeProposal(proposalId, ct);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to process due reschedule voting proposals");
}
}
private async Task FinalizeProposal(Guid proposalId, CancellationToken ct)
{
var result = await finalizer.FinalizeAsync(proposalId, ct);
if (result is null)
return;
if (result.SourcePlatform != "Telegram")
{
logger.LogInformation(
"Skipping Telegram message handling for proposal {ProposalId} with source platform {SourcePlatform}",
proposalId,
result.SourcePlatform);
return;
}
await using var connection = await dataSource.OpenConnectionAsync(ct);
var telegramFields = await connection.QuerySingleOrDefaultAsync<TelegramProposalFieldsDto>(
"""
SELECT rp.vote_message_id AS VoteMessageId,
s.batch_message_id AS BatchMessageId,
g.external_group_id::BIGINT AS TelegramChatId,
s.thread_id AS ThreadId
FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id
JOIN game_groups g ON g.id = s.group_id
WHERE rp.id = @ProposalId
""",
new { ProposalId = proposalId });
if (telegramFields is null)
{
logger.LogWarning("Could not find Telegram fields for proposal {ProposalId}", proposalId);
return;
}
var directRecipients = result.Participants
.Select(p => TelegramPlatformIds.User(p.TelegramId, p.DisplayName))
.ToList();
await TryUpdateVoteMessage(result, telegramFields, ct);
if (result.SelectedOption is not null)
{
await TryUpdateBatchMessage(result, telegramFields, ct);
}
var mode = SessionNotificationModeExtensions.FromDatabaseValue(result.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
await SendDirectResult(result, directRecipients, ct);
}
logger.LogInformation(
"Updated Telegram messages for finalized reschedule proposal {ProposalId} for session {SessionId}",
result.ProposalId,
result.SessionId);
}
private async Task TryUpdateVoteMessage(
RescheduleVotingFinalizerResult result,
TelegramProposalFieldsDto telegramFields,
CancellationToken ct)
{
if (telegramFields.VoteMessageId is null)
return;
try
{
await messenger.UpdateRescheduleVoteAsync(
new PlatformRescheduleVoteUpdate(
TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
TelegramPlatformIds.Message(
telegramFields.TelegramChatId,
telegramFields.ThreadId,
telegramFields.VoteMessageId.Value),
result.ProposalId,
result.SessionId,
result.Title,
result.CurrentScheduledAt,
result.VotingDeadlineAt,
result.Decision,
result.SelectedOption,
result.Options,
result.Votes,
result.Participants),
ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update finalized reschedule vote message for proposal {ProposalId}", result.ProposalId);
}
}
private async Task TryUpdateBatchMessage(
RescheduleVotingFinalizerResult result,
TelegramProposalFieldsDto telegramFields,
CancellationToken ct)
{
try
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { result.BatchId })).ToList();
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
p.external_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
JOIN sessions s ON sp.session_id = s.id
WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
""",
new { result.BatchId })).ToList();
if (telegramFields.BatchMessageId.HasValue)
{
var view = SessionBatchViewBuilder.Build(result.Title, batchSessions, batchParticipants);
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
view,
TelegramPlatformIds.Message(telegramFields.TelegramChatId, telegramFields.ThreadId, telegramFields.BatchMessageId.Value)),
ct);
}
else
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
$"Расписание обновлено после голосования за перенос сессии \"{System.Net.WebUtility.HtmlEncode(result.Title)}\".",
ct);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update batch message for finalized proposal {ProposalId}", result.ProposalId);
}
}
private async Task SendDirectResult(
RescheduleVotingFinalizerResult result,
IReadOnlyList<PlatformUser> recipients,
CancellationToken ct)
{
await directSender.SendAsync(
result.SelectedOption is not null
? PlatformDirectSessionNotificationKind.RescheduleApproved
: PlatformDirectSessionNotificationKind.RescheduleRejected,
recipients,
result.SessionId,
result.Title,
result.SelectedOption?.ProposedAt.UtcDateTime ?? result.CurrentScheduledAt,
joinLink: null,
actorDisplayName: null,
reason: result.SelectedOption is null ? result.Decision.Reason : null,
ct);
}
}
+1 -1
View File
@@ -22,7 +22,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Aspire.Npgsql" Version="13.2.1" />
<PackageReference Include="Aspire.Npgsql" Version="13.2.2" />
<PackageReference Include="Dapper" Version="2.1.72" />
<PackageReference Include="Dapper.AOT" Version="1.0.48" />
<PackageReference Include="dbup-postgresql" Version="7.0.1" />
@@ -0,0 +1,101 @@
using System.Net;
namespace GmRelay.Bot.Infrastructure.Health;
public sealed class BotHealthCheckHostedService : IHostedService
{
private readonly ILogger<BotHealthCheckHostedService> _logger;
private readonly string _prefix;
private HttpListener? _listener;
private CancellationTokenSource? _cts;
private Task? _listenerTask;
public BotHealthCheckHostedService(
ILogger<BotHealthCheckHostedService> logger,
IConfiguration configuration)
{
_logger = logger;
_prefix = configuration.GetValue("HealthCheck:Prefix", "http://+:8081/")!;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_cts = new CancellationTokenSource();
_listener = new HttpListener();
_listener.Prefixes.Add(_prefix);
_listener.Start();
_logger.LogInformation("Health check server started on {Prefix}", _prefix);
_listenerTask = Task.Run(async () => await ListenAsync(_cts.Token), cancellationToken);
return Task.CompletedTask;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
_cts?.Cancel();
_listener?.Stop();
if (_listenerTask != null)
{
await Task.WhenAny(_listenerTask, Task.Delay(TimeSpan.FromSeconds(5), cancellationToken));
}
_listener?.Close();
_logger.LogInformation("Health check server stopped");
}
private async Task ListenAsync(CancellationToken cancellationToken)
{
while (_listener?.IsListening == true && !cancellationToken.IsCancellationRequested)
{
try
{
var context = await _listener.GetContextAsync();
_ = Task.Run(() => HandleRequestAsync(context), cancellationToken);
}
catch (HttpListenerException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (ObjectDisposedException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in health check listener");
}
}
}
private async Task HandleRequestAsync(HttpListenerContext context)
{
var response = context.Response;
try
{
var request = context.Request;
if (request.Url?.AbsolutePath == "/health")
{
response.StatusCode = (int)HttpStatusCode.OK;
response.ContentType = "application/json";
var body = "{\"status\":\"healthy\"}"u8.ToArray();
await response.OutputStream.WriteAsync(body);
}
else
{
response.StatusCode = (int)HttpStatusCode.NotFound;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling health check request");
}
finally
{
response.Close();
}
}
}
@@ -0,0 +1,47 @@
using System.Text.RegularExpressions;
using Npgsql;
namespace GmRelay.Bot.Infrastructure.Logging;
public static partial class SecretRedactor
{
public static string RedactConnectionString(string? connectionString)
{
if (string.IsNullOrWhiteSpace(connectionString))
{
return string.Empty;
}
try
{
var builder = new NpgsqlConnectionStringBuilder(connectionString);
if (!string.IsNullOrWhiteSpace(builder.Password))
{
builder.Password = "***";
}
return builder.ToString();
}
catch (ArgumentException)
{
return RedactText(connectionString);
}
}
public static string RedactText(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return string.Empty;
}
return SecretKeyValueRegex().Replace(
text,
static match => $"{match.Groups["key"].Value}={GetRedactedValue()}");
}
private static string GetRedactedValue() => "***";
[GeneratedRegex(@"(?<key>password|pwd|passwd|token|secret|api[-_]?key)\s*=\s*(?<value>[^;\s,]+)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex SecretKeyValueRegex();
}
@@ -0,0 +1,13 @@
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Infrastructure.Scheduling;
public sealed class SystemClock : ISystemClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
public sealed class FakeSystemClock : ISystemClock
{
public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UtcNow;
}
@@ -1,117 +0,0 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
using GmRelay.Bot.Features.Reminders.SendJoinLink;
using Npgsql;
namespace GmRelay.Bot.Infrastructure.Scheduling;
/// <summary>
/// Stateless scheduler: wakes every 60 seconds, queries PostgreSQL for actionable sessions.
/// Two triggers:
/// T-24h: send confirmation request with inline keyboard
/// T-5min: send join link to all confirmed players
///
/// If the Raspberry Pi reboots, nothing is lost — all state is in the DB.
/// </summary>
public sealed class SessionSchedulerService(
NpgsqlDataSource dataSource,
SendConfirmationHandler confirmationHandler,
SendJoinLinkHandler joinLinkHandler,
ILogger<SessionSchedulerService> logger) : BackgroundService
{
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
private static readonly TimeSpan ConfirmationLeadTime = TimeSpan.FromHours(24);
private static readonly TimeSpan JoinLinkLeadTime = TimeSpan.FromMinutes(5);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Session scheduler started (interval: {Interval})", TickInterval);
using var timer = new PeriodicTimer(TickInterval);
// Run immediately on startup, then on each tick
do
{
try
{
await ProcessConfirmationTriggers(stoppingToken);
await ProcessJoinLinkTriggers(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Scheduler tick failed, will retry next tick");
}
}
while (await timer.WaitForNextTickAsync(stoppingToken));
logger.LogInformation("Session scheduler stopped");
}
/// <summary>
/// T-24h trigger: find sessions that need confirmation requests sent.
/// Condition: status='Planned' AND scheduled_at minus 24h is in the past.
/// </summary>
private async Task ProcessConfirmationTriggers(CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var sessionIds = await connection.QueryAsync<Guid>(
"""
SELECT id
FROM sessions
WHERE status = @Planned
AND scheduled_at - @LeadTime <= now()
""",
new { Planned = SessionStatus.Planned, LeadTime = ConfirmationLeadTime });
foreach (var sessionId in sessionIds)
{
try
{
await confirmationHandler.HandleAsync(sessionId, ct);
logger.LogInformation("Confirmation sent for session {SessionId}", sessionId);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send confirmation for session {SessionId}", sessionId);
}
}
}
/// <summary>
/// T-5min trigger: find confirmed sessions that need join links sent.
/// Condition: status='Confirmed' AND scheduled_at minus 5min is in the past AND link not yet sent.
/// </summary>
private async Task ProcessJoinLinkTriggers(CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var sessionIds = await connection.QueryAsync<Guid>(
"""
SELECT id
FROM sessions
WHERE status = @Confirmed
AND scheduled_at - @LeadTime <= now()
AND link_message_id IS NULL
""",
new { Confirmed = SessionStatus.Confirmed, LeadTime = JoinLinkLeadTime });
foreach (var sessionId in sessionIds)
{
try
{
await joinLinkHandler.HandleAsync(sessionId, ct);
logger.LogInformation("Join link sent for session {SessionId}", sessionId);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send join link for session {SessionId}", sessionId);
}
}
}
}
@@ -0,0 +1,51 @@
using Telegram.Bot;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Infrastructure.Telegram;
/// <summary>
/// Handles editing batch messages that may be either text or photo messages.
/// When the batch was created with SendPhoto (image + caption), we need
/// EditMessageCaption instead of EditMessageText.
/// </summary>
public static class BatchMessageEditor
{
/// <summary>
/// Edits a batch message, automatically detecting whether it is a text or photo message.
/// Tries EditMessageText first; on failure falls back to EditMessageCaption.
/// </summary>
public static async Task EditBatchMessageAsync(
ITelegramBotClient bot,
long chatId,
int messageId,
string text,
InlineKeyboardMarkup? replyMarkup,
CancellationToken ct = default)
{
try
{
await bot.EditMessageText(
chatId: chatId,
messageId: messageId,
text: text,
parseMode: ParseMode.Html,
replyMarkup: replyMarkup,
cancellationToken: ct);
}
catch (global::Telegram.Bot.Exceptions.ApiRequestException ex)
when (ex.Message.Contains("there is no text in the message", StringComparison.OrdinalIgnoreCase))
{
// The batch message is a photo — use EditMessageCaption instead.
// Caption is limited to 1024 chars; if text exceeds that, truncate gracefully.
var caption = text.Length <= 1024 ? text : text[..1021] + "...";
await bot.EditMessageCaption(
chatId: chatId,
messageId: messageId,
caption: caption,
parseMode: ParseMode.Html,
replyMarkup: replyMarkup,
cancellationToken: ct);
}
}
}
@@ -0,0 +1,8 @@
using Telegram.Bot.Types;
namespace GmRelay.Bot.Infrastructure.Telegram;
public interface ITelegramUpdateHandler
{
Task RouteAsync(Update update, CancellationToken ct);
}
@@ -0,0 +1,14 @@
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
namespace GmRelay.Bot.Infrastructure.Telegram;
public interface ITelegramUpdateSource
{
Task<Update[]> GetUpdatesAsync(
int offset,
int? limit = null,
int? timeout = null,
IEnumerable<UpdateType>? allowedUpdates = null,
CancellationToken cancellationToken = default);
}
@@ -1,4 +1,3 @@
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
@@ -9,35 +8,21 @@ namespace GmRelay.Bot.Infrastructure.Telegram;
/// Stateless — all state is in PostgreSQL. Safe to restart at any time.
/// </summary>
public sealed class TelegramBotService(
ITelegramBotClient bot,
UpdateRouter router,
ITelegramUpdateSource updateSource,
ITelegramUpdateHandler updateHandler,
ILogger<TelegramBotService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Telegram bot polling started");
// Skip any pending updates from before this startup
try
{
var pending = await bot.GetUpdates(offset: -1, limit: 1, cancellationToken: stoppingToken);
if (pending.Length > 0)
{
logger.LogInformation("Skipped {Count} pending update(s)", pending[^1].Id);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to clear pending updates, continuing anyway");
}
var offset = 0;
var offset = await GetStartupOffsetAsync(stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var updates = await bot.GetUpdates(
var updates = await updateSource.GetUpdatesAsync(
offset: offset,
timeout: 30,
allowedUpdates: [UpdateType.Message, UpdateType.CallbackQuery],
@@ -47,7 +32,7 @@ public sealed class TelegramBotService(
{
try
{
await router.RouteAsync(update, stoppingToken);
await updateHandler.RouteAsync(update, stoppingToken);
}
catch (Exception ex)
{
@@ -70,4 +55,33 @@ public sealed class TelegramBotService(
logger.LogInformation("Telegram bot polling stopped");
}
private async Task<int> GetStartupOffsetAsync(CancellationToken stoppingToken)
{
try
{
var pending = await updateSource.GetUpdatesAsync(
offset: -1,
limit: 1,
cancellationToken: stoppingToken);
if (pending.Length == 0)
{
return 0;
}
var startupOffset = pending[^1].Id + 1;
logger.LogInformation(
"Skipping pending updates through {LastPendingUpdateId}; starting polling from offset {StartupOffset}",
pending[^1].Id,
startupOffset);
return startupOffset;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to determine startup offset, continuing from offset 0");
return 0;
}
}
}
@@ -0,0 +1,46 @@
using Telegram.Bot;
using Telegram.Bot.Types;
namespace GmRelay.Bot.Infrastructure.Telegram;
public sealed class TelegramMiniAppMenuButtonService(
ITelegramBotClient bot,
IConfiguration configuration,
ILogger<TelegramMiniAppMenuButtonService> logger) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
var miniAppUrl = configuration["Telegram:MiniAppUrl"];
if (string.IsNullOrWhiteSpace(miniAppUrl))
{
logger.LogInformation("Telegram Mini App URL is not configured; menu button setup skipped.");
return;
}
if (!Uri.TryCreate(miniAppUrl, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttps && !uri.IsLoopback))
{
logger.LogWarning("Telegram Mini App URL {MiniAppUrl} is not a valid HTTPS URL.", miniAppUrl);
return;
}
try
{
await bot.SetChatMenuButton(
menuButton: new MenuButtonWebApp
{
Text = "Dashboard",
WebApp = new WebAppInfo(miniAppUrl)
},
cancellationToken: cancellationToken);
logger.LogInformation("Telegram Mini App menu button configured for {MiniAppUrl}.", miniAppUrl);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to configure Telegram Mini App menu button.");
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -0,0 +1,29 @@
using System.Globalization;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Infrastructure.Telegram;
internal static class TelegramPlatformIds
{
public static PlatformGroup Group(long chatId, int? threadId = null, string? displayName = null) =>
new(
PlatformKind.Telegram,
chatId.ToString(CultureInfo.InvariantCulture),
displayName ?? "Telegram chat",
ExternalChannelId: chatId.ToString(CultureInfo.InvariantCulture),
ExternalThreadId: threadId?.ToString(CultureInfo.InvariantCulture));
public static PlatformUser User(long telegramId, string displayName, string? username = null) =>
new(
PlatformKind.Telegram,
telegramId.ToString(CultureInfo.InvariantCulture),
displayName,
username);
public static PlatformMessageRef Message(long chatId, int? threadId, int messageId) =>
new(
PlatformKind.Telegram,
chatId.ToString(CultureInfo.InvariantCulture),
threadId?.ToString(CultureInfo.InvariantCulture),
messageId.ToString(CultureInfo.InvariantCulture));
}
@@ -0,0 +1,517 @@
using System.Globalization;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Infrastructure.Telegram;
public sealed class TelegramPlatformMessenger(
ITelegramBotClient bot,
ILogger<TelegramPlatformMessenger> logger) : IPlatformMessenger
{
public async Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
{
EnsureTelegram(message.Group.Platform);
var chatId = ParseLong(message.Group.ExternalGroupId);
var threadId = ParseNullableInt(message.Group.ExternalThreadId);
var renderResult = TelegramSessionBatchRenderer.Render(message.View);
Message sentMessage;
if (!string.IsNullOrWhiteSpace(message.ImageReference) && renderResult.Text.Length <= 1024)
{
try
{
sentMessage = await bot.SendPhoto(
chatId: chatId,
messageThreadId: threadId,
photo: InputFile.FromString(message.ImageReference),
caption: renderResult.Text,
parseMode: ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send Telegram schedule image for group {ExternalGroupId}", message.Group.ExternalGroupId);
sentMessage = await SendScheduleTextMessage(chatId, threadId, renderResult.Text, renderResult.Markup, ct);
}
}
else
{
if (!string.IsNullOrWhiteSpace(message.ImageReference))
{
await TrySendScheduleImageOnly(chatId, threadId, message.View.Title, message.ImageReference, ct);
}
sentMessage = await SendScheduleTextMessage(chatId, threadId, renderResult.Text, renderResult.Markup, ct);
}
return new PlatformMessageRef(
PlatformKind.Telegram,
message.Group.ExternalGroupId,
message.Group.ExternalThreadId,
sentMessage.MessageId.ToString(CultureInfo.InvariantCulture));
}
public async Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
{
EnsureTelegram(message.Group.Platform);
var existingMessage = message.ExistingMessage;
if (existingMessage is null)
{
throw new ArgumentException("Existing schedule message reference is required.", nameof(message));
}
EnsureTelegram(existingMessage.Platform);
if (!string.Equals(message.Group.ExternalGroupId, existingMessage.ExternalGroupId, StringComparison.Ordinal) ||
!string.Equals(message.Group.ExternalThreadId, existingMessage.ExternalThreadId, StringComparison.Ordinal))
{
throw new ArgumentException("Existing schedule message reference must match the schedule group.", nameof(message));
}
var renderResult = TelegramSessionBatchRenderer.Render(message.View);
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: ParseLong(existingMessage.ExternalGroupId),
messageId: ParseInt(existingMessage.ExternalMessageId),
text: renderResult.Text,
replyMarkup: renderResult.Markup,
ct);
}
public Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct)
{
EnsureTelegram(group.Platform);
return bot.SendMessage(
chatId: ParseLong(group.ExternalGroupId),
messageThreadId: ParseNullableInt(group.ExternalThreadId),
text: htmlText,
parseMode: ParseMode.Html,
cancellationToken: ct);
}
public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
{
EnsureTelegram(group.Platform);
await bot.SendMessage(
chatId: ParseLong(group.ExternalGroupId),
messageThreadId: ParseNullableInt(group.ExternalThreadId),
text: htmlText,
parseMode: ParseMode.Html,
replyMarkup: BuildActionsMarkup(actions),
cancellationToken: ct);
}
public async Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
{
EnsureTelegram(messageRef.Platform);
await bot.EditMessageText(
chatId: ParseLong(messageRef.ExternalGroupId),
messageId: ParseInt(messageRef.ExternalMessageId),
text: htmlText,
parseMode: ParseMode.Html,
replyMarkup: BuildActionsMarkup(actions),
cancellationToken: ct);
}
public async Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct)
{
EnsureTelegram(group.Platform);
var topic = await bot.CreateForumTopic(
chatId: ParseLong(group.ExternalGroupId),
name: title,
cancellationToken: ct);
return new PlatformMessageRef(
PlatformKind.Telegram,
group.ExternalGroupId,
topic.MessageThreadId.ToString(CultureInfo.InvariantCulture),
string.Empty);
}
public Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct)
{
EnsureTelegram(group.Platform);
if (string.IsNullOrWhiteSpace(group.ExternalThreadId))
{
return Task.CompletedTask;
}
return bot.DeleteForumTopic(
ParseLong(group.ExternalGroupId),
ParseInt(group.ExternalThreadId),
cancellationToken: ct);
}
public Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct)
{
EnsureTelegram(messageRef.Platform);
return bot.DeleteMessage(
ParseLong(messageRef.ExternalGroupId),
ParseInt(messageRef.ExternalMessageId),
cancellationToken: ct);
}
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
{
EnsureTelegram(message.Recipient.Platform);
return bot.SendMessage(
chatId: ParseLong(message.Recipient.ExternalUserId),
text: message.HtmlText,
parseMode: ParseMode.Html,
cancellationToken: ct);
}
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct) =>
bot.AnswerCallbackQuery(
callbackQueryId: reply.InteractionId,
text: reply.Text,
showAlert: reply.ShowAlert,
cancellationToken: ct);
public async Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct)
{
EnsureTelegram(file.Group.Platform);
using var stream = new MemoryStream(file.Content);
await bot.SendDocument(
chatId: ParseLong(file.Group.ExternalGroupId),
messageThreadId: ParseNullableInt(file.Group.ExternalThreadId),
document: InputFile.FromStream(stream, file.FileName),
caption: file.CaptionHtml,
parseMode: ParseMode.Html,
replyMarkup: BuildActionsMarkup(file.Actions),
cancellationToken: ct);
}
public async Task<PlatformMessageRef> SendConfirmationRequestAsync(PlatformConfirmationRequest request, CancellationToken ct)
{
EnsureTelegram(request.Group.Platform);
var chatId = ParseLong(request.Group.ExternalGroupId);
var threadId = ParseNullableInt(request.Group.ExternalThreadId);
var message = await bot.SendMessage(
chatId: chatId,
messageThreadId: threadId,
text: BuildConfirmationText(request),
parseMode: ParseMode.Html,
replyMarkup: BuildRsvpKeyboard(request.SessionId),
cancellationToken: ct);
return TelegramPlatformIds.Message(chatId, threadId, message.MessageId);
}
public async Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct)
{
var request = update.Request;
EnsureTelegram(request.Group.Platform);
var existingMessage = request.ExistingMessage
?? throw new ArgumentException("Existing confirmation message reference is required.", nameof(update));
EnsureTelegram(existingMessage.Platform);
await bot.EditMessageText(
chatId: ParseLong(existingMessage.ExternalGroupId),
messageId: ParseInt(existingMessage.ExternalMessageId),
text: BuildConfirmationText(request),
parseMode: ParseMode.Html,
replyMarkup: update.DisableActions ? null : BuildRsvpKeyboard(request.SessionId),
cancellationToken: ct);
}
public async Task<PlatformMessageRef> SendJoinLinkNotificationAsync(
PlatformJoinLinkNotification notification,
CancellationToken ct)
{
EnsureTelegram(notification.Group.Platform);
var chatId = ParseLong(notification.Group.ExternalGroupId);
var threadId = ParseNullableInt(notification.Group.ExternalThreadId);
var message = await bot.SendMessage(
chatId: chatId,
messageThreadId: threadId,
text: BuildJoinLinkText(notification),
cancellationToken: ct);
return TelegramPlatformIds.Message(chatId, threadId, message.MessageId);
}
public Task SendDirectSessionNotificationAsync(
PlatformDirectSessionNotification notification,
CancellationToken ct)
{
EnsureTelegram(notification.Recipient.Platform);
return bot.SendMessage(
chatId: ParseLong(notification.Recipient.ExternalUserId),
text: BuildDirectNotificationText(notification),
parseMode: ParseMode.Html,
cancellationToken: ct);
}
public async Task SendRsvpOutcomeAsync(PlatformRsvpOutcomeNotification notification, CancellationToken ct)
{
switch (notification.Kind)
{
case PlatformRsvpOutcomeKind.GroupAllConfirmed:
if (notification.Group is null)
{
throw new ArgumentException("Group notification requires a group.", nameof(notification));
}
EnsureTelegram(notification.Group.Platform);
await bot.SendMessage(
chatId: ParseLong(notification.Group.ExternalGroupId),
messageThreadId: ParseNullableInt(notification.Group.ExternalThreadId),
text: $"🎉 Игра «{notification.Title}» подтверждена! Все участники на месте.",
cancellationToken: ct);
break;
case PlatformRsvpOutcomeKind.GmAllConfirmed:
case PlatformRsvpOutcomeKind.GmPlayerDeclined:
foreach (var recipient in notification.Recipients)
{
EnsureTelegram(recipient.Platform);
await bot.SendMessage(
chatId: ParseLong(recipient.ExternalUserId),
text: BuildRsvpOutcomeDirectText(notification),
parseMode: ParseMode.Html,
cancellationToken: ct);
}
break;
default:
throw new ArgumentOutOfRangeException(nameof(notification), notification.Kind, "Unknown RSVP outcome kind.");
}
}
public Task UpdateRescheduleVoteAsync(PlatformRescheduleVoteUpdate update, CancellationToken ct)
{
EnsureTelegram(update.Group.Platform);
EnsureTelegram(update.ExistingMessage.Platform);
var resultText = update.SelectedOption is not null
? $"✅ <b>Голосование завершено.</b>\nПобедил вариант {update.SelectedOption.DisplayOrder}: <b>{update.SelectedOption.ProposedAt.FormatMoscow()}</b> (МСК)."
: $"❌ <b>Голосование завершено.</b>\n{System.Net.WebUtility.HtmlEncode(update.Decision.Reason)}";
var text = $"""
{HandleRescheduleTimeInputHandler.BuildVotingMessage(
update.Title,
update.CurrentScheduledAt,
update.VotingDeadlineAt,
update.Options,
update.Participants,
update.Votes)}
{resultText}
""";
return bot.EditMessageText(
chatId: ParseLong(update.ExistingMessage.ExternalGroupId),
messageId: ParseInt(update.ExistingMessage.ExternalMessageId),
text: text,
parseMode: ParseMode.Html,
cancellationToken: ct);
}
private async Task<Message> SendScheduleTextMessage(
long chatId,
int? threadId,
string text,
InlineKeyboardMarkup markup,
CancellationToken ct) =>
await bot.SendMessage(
chatId: chatId,
messageThreadId: threadId,
text: text,
parseMode: ParseMode.Html,
replyMarkup: markup,
cancellationToken: ct);
private static string BuildConfirmationText(PlatformConfirmationRequest request)
{
var confirmed = request.Participants.Where(p => p.RsvpStatus == RsvpStatus.Confirmed).ToList();
var declined = request.Participants.Where(p => p.RsvpStatus == RsvpStatus.Declined).ToList();
var pending = request.Participants.Where(p => p.RsvpStatus == RsvpStatus.Pending).ToList();
var lines = new List<string>
{
$"🎲 Подтвердите участие в «{System.Net.WebUtility.HtmlEncode(request.Title)}»",
$"📅 {request.ScheduledAt.FormatMoscow()} (МСК)",
string.Empty
};
foreach (var participant in confirmed)
{
lines.Add($" ✅ {FormatTelegramParticipant(participant)}");
}
foreach (var participant in declined)
{
lines.Add($" ❌ <s>{FormatTelegramParticipant(participant)}</s>");
}
foreach (var participant in pending)
{
lines.Add($" ⏳ {FormatTelegramParticipant(participant)}");
}
lines.Add(string.Empty);
if (request.Participants.Count > 0 && confirmed.Count == request.Participants.Count)
{
lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{request.Participants.Count})");
}
else if (declined.Count > 0)
{
lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{request.Participants.Count} подтвердили)");
}
else
{
lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{request.Participants.Count})");
}
return string.Join("\n", lines);
}
private static string BuildJoinLinkText(PlatformJoinLinkNotification notification)
{
var mentions = string.Join(", ", notification.ConfirmedPlayers.Select(FormatTelegramParticipant));
return $"""
🎮 Игра «{notification.Title}» начинается через 5 минут!
🔗 Ссылка на подключение:
{notification.JoinLink}
Участники: {mentions}
Хорошей игры! 🎲
""";
}
private static string BuildDirectNotificationText(PlatformDirectSessionNotification notification) =>
notification.Kind switch
{
PlatformDirectSessionNotificationKind.ConfirmationRequest => $"""
🎲 <b>Подтвердите участие в игре</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
📅 {notification.ScheduledAt.FormatMoscow()} (МСК)
Ответьте кнопкой в групповом сообщении расписания.
""",
PlatformDirectSessionNotificationKind.OneHourReminder => $"""
⏰ <b>Игра начнётся примерно через 1 час</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
📅 {notification.ScheduledAt.FormatMoscow()} (МСК)
🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)}
""",
PlatformDirectSessionNotificationKind.JoinLink => $"""
🎮 <b>Игра начинается через 5 минут</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)}
""",
PlatformDirectSessionNotificationKind.RescheduleApproved => $"""
✅ <b>Сессия перенесена по итогам голосования</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
📅 Новое время: <b>{notification.ScheduledAt.FormatMoscow()}</b> (МСК)
""",
PlatformDirectSessionNotificationKind.RescheduleRejected => $"""
❌ <b>Перенос сессии отклонён по итогам голосования</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
📅 Время остаётся прежним: <b>{notification.ScheduledAt.FormatMoscow()}</b> (МСК)
Причина: {System.Net.WebUtility.HtmlEncode(notification.Reason ?? string.Empty)}
""",
_ => BuildFallbackDirectText(notification)
};
private static string BuildFallbackDirectText(PlatformDirectSessionNotification notification) =>
$"<b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>\n{notification.ScheduledAt.FormatMoscow()} (МСК)";
private static string BuildRsvpOutcomeDirectText(PlatformRsvpOutcomeNotification notification) =>
notification.Kind switch
{
PlatformRsvpOutcomeKind.GmAllConfirmed =>
$"✅ Все подтвердили участие в «{System.Net.WebUtility.HtmlEncode(notification.Title)}» ({notification.ScheduledAt.FormatMoscow()} МСК).",
PlatformRsvpOutcomeKind.GmPlayerDeclined =>
$"🚨 Отмена! {System.Net.WebUtility.HtmlEncode(notification.ActorDisplayName ?? "Игрок")} не сможет прийти на игру «{System.Net.WebUtility.HtmlEncode(notification.Title)}».",
_ => System.Net.WebUtility.HtmlEncode(notification.Title)
};
private static InlineKeyboardMarkup BuildRsvpKeyboard(Guid sessionId) =>
new([
[
InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{sessionId}"),
InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{sessionId}")
]
]);
private static string FormatTelegramParticipant(PlatformSessionParticipant participant) =>
participant.User.ExternalUsername is not null
? $"@{participant.User.ExternalUsername}"
: System.Net.WebUtility.HtmlEncode(participant.User.DisplayName);
private async Task TrySendScheduleImageOnly(
long chatId,
int? threadId,
string title,
string imageReference,
CancellationToken ct)
{
try
{
await bot.SendPhoto(
chatId: chatId,
messageThreadId: threadId,
photo: InputFile.FromString(imageReference),
caption: $"🎲 {System.Net.WebUtility.HtmlEncode(title)}",
parseMode: ParseMode.Html,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send Telegram schedule image for chat {ChatId}", chatId);
}
}
private static InlineKeyboardMarkup? BuildActionsMarkup(IReadOnlyList<PlatformMessageAction> actions)
{
if (actions.Count == 0)
{
return null;
}
return new InlineKeyboardMarkup(
actions.Select(action => new[]
{
Uri.TryCreate(action.Payload, UriKind.Absolute, out var uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)
? InlineKeyboardButton.WithUrl(action.Label, action.Payload)
: InlineKeyboardButton.WithCallbackData(action.Label, action.Payload)
}));
}
private static void EnsureTelegram(PlatformKind platform)
{
if (platform != PlatformKind.Telegram)
{
throw new NotSupportedException($"Telegram messenger cannot send messages for platform {platform}.");
}
}
private static long ParseLong(string value) => long.Parse(value, CultureInfo.InvariantCulture);
private static int ParseInt(string value) => int.Parse(value, CultureInfo.InvariantCulture);
private static int? ParseNullableInt(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : int.Parse(value, CultureInfo.InvariantCulture);
}
@@ -0,0 +1,63 @@
// NOTE: duplicated in GmRelay.Web/Services/TelegramSessionBatchRenderer.cs
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Infrastructure.Telegram;
public static class TelegramSessionBatchRenderer
{
public static (string Text, InlineKeyboardMarkup Markup) Render(SessionBatchViewModel view)
{
var messageText = $"🎲 <b>Новые игры:</b> {System.Net.WebUtility.HtmlEncode(view.Title)}\n\n" +
$"<b>Расписание:</b>\n\n";
var buttons = new List<InlineKeyboardButton[]>();
foreach (var session in view.Sessions)
{
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
messageText += session.MaxPlayers.HasValue
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
: $"👥 Игроки ({session.ActivePlayerCount}):\n";
if (!string.IsNullOrEmpty(session.JoinLink))
{
messageText += $"🔗 <a href=\"{System.Net.WebUtility.HtmlEncode(session.JoinLink)}\">Ссылка на игру</a>\n";
}
if (session.ActivePlayers.Count > 0)
{
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
$" 👤 {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
}
else
{
messageText += " <i>Пока никто не записался</i>\n";
}
if (session.WaitlistedPlayers.Count > 0)
{
messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n";
messageText += string.Join("\n", session.WaitlistedPlayers.Select(p =>
$" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
}
if (GmRelay.Shared.Domain.SessionStatus.IsCancelled(session.Status))
{
messageText += "❌ <i>Сессия отменена</i>\n\n";
}
else
{
messageText += "\n";
var actionRow = session.AvailableActions
.Select(a => InlineKeyboardButton.WithCallbackData(a.Label, $"{a.ActionKey}:{a.SessionId}"))
.ToArray();
if (actionRow.Length > 0)
buttons.Add(actionRow);
}
}
return (messageText, new InlineKeyboardMarkup(buttons));
}
}
@@ -0,0 +1,40 @@
namespace GmRelay.Bot.Infrastructure.Telegram;
public sealed record TelegramTopicDestination(
int? MessageThreadId,
bool ShouldCreateForumTopic,
bool TopicCreatedByBot);
public static class TelegramTopicRouting
{
public const string MissingForumTopicRightsMessage =
"Не удалось создать Telegram topic. Сделайте бота admin и включите право Manage Topics, затем повторите команду.";
public static TelegramTopicDestination ResolveNewScheduleDestination(
bool chatIsForum,
int? incomingMessageThreadId)
{
if (!chatIsForum)
{
return new TelegramTopicDestination(null, ShouldCreateForumTopic: false, TopicCreatedByBot: false);
}
if (incomingMessageThreadId.HasValue)
{
return new TelegramTopicDestination(
incomingMessageThreadId,
ShouldCreateForumTopic: false,
TopicCreatedByBot: false);
}
return new TelegramTopicDestination(null, ShouldCreateForumTopic: true, TopicCreatedByBot: true);
}
public static bool ShouldDeleteForumTopic(bool topicCreatedByBot, int remainingSessionsInTopic) =>
topicCreatedByBot && remainingSessionsInTopic == 0;
public static bool IsMissingForumTopicRightsError(string apiError) =>
apiError.Contains("not enough rights", StringComparison.OrdinalIgnoreCase) ||
apiError.Contains("CHAT_ADMIN_REQUIRED", StringComparison.OrdinalIgnoreCase) ||
apiError.Contains("not an administrator", StringComparison.OrdinalIgnoreCase);
}
@@ -0,0 +1,21 @@
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
namespace GmRelay.Bot.Infrastructure.Telegram;
public sealed class TelegramUpdateSource(ITelegramBotClient bot) : ITelegramUpdateSource
{
public Task<Update[]> GetUpdatesAsync(
int offset,
int? limit = null,
int? timeout = null,
IEnumerable<UpdateType>? allowedUpdates = null,
CancellationToken cancellationToken = default) =>
bot.GetUpdates(
offset: offset,
limit: limit,
timeout: timeout,
allowedUpdates: allowedUpdates,
cancellationToken: cancellationToken);
}
@@ -1,14 +1,23 @@
// ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment
using System.Globalization;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Rendering;
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Bot.Features.Sessions.ListSessions;
using BotCreateSessionHandler = GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler;
using BotRescheduleTimeInputHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
using BotRescheduleVoteHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler;
using GmRelay.Bot.Features.Sessions.ExportCalendar;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
using SharedWizard = GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard;
namespace GmRelay.Bot.Infrastructure.Telegram;
@@ -18,37 +27,181 @@ namespace GmRelay.Bot.Infrastructure.Telegram;
/// </summary>
public sealed class UpdateRouter(
HandleRsvpHandler rsvpHandler,
CreateSessionHandler createSessionHandler,
BotCreateSessionHandler createSessionHandler,
JoinSessionHandler joinSessionHandler,
LeaveSessionHandler leaveSessionHandler,
PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler,
CancelSessionHandler cancelSessionHandler,
DeleteSessionHandler deleteSessionHandler,
ListSessionsHandler listSessionsHandler,
ExportCalendarHandler exportCalendarHandler,
InitiateRescheduleHandler initiateRescheduleHandler,
HandleRescheduleTimeInputHandler rescheduleTimeInputHandler,
HandleRescheduleVoteHandler rescheduleVoteHandler,
BotRescheduleTimeInputHandler rescheduleTimeInputHandler,
BotRescheduleVoteHandler rescheduleVoteHandler,
SharedWizard wizard,
IWizardDraftRepository drafts,
ITelegramBotClient bot,
ILogger<UpdateRouter> logger)
IConfiguration configuration,
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
{
public async Task RouteAsync(Update update, CancellationToken ct)
{
// 1) Wizard delegation. If the GM has an active (non-expired) draft for this
// (chat, thread, owner), every update routes to the wizard. The wizard is
// responsible for both text input and callback handling.
if (TryGetWizardContext(update, out _, out _, out var ownerId))
{
var draft = await drafts.GetActiveAsync("Telegram", ownerId, ct);
if (draft is not null)
{
// Resume / Reset / Cancel menu callbacks live in the router because
// they cross draft boundaries (reset deletes + recreates a fresh
// draft, which the wizard instance doesn't know how to do).
if (await TryHandleDraftControlCallbackAsync(update, draft, ct))
{
return;
}
if (WizardInteractionMapper.TryMap(update, out var interaction))
{
await wizard.HandleInteractionAsync(interaction, draft, ct);
}
// The "✅ Создать" / "✅ Создать пул" button — the wizard only
// acknowledges the callback; the actual session creation lives in
// CreateSessionHandler.
if (update.CallbackQuery?.Data is { } data &&
data == WizardCallbackData.Create())
{
await createSessionHandler.SubmitDraftAsync(draft, ct);
}
return;
}
}
switch (update)
{
case { CallbackQuery: { } query }:
await HandleCallbackQueryAsync(query, ct);
break;
case { Message: { Text: { } text } message } when text.StartsWith('/'):
await HandleCommandAsync(message, text, ct);
break;
case { Message: { } message }:
var commandText = GetCommandText(message);
if (commandText.StartsWith("/", StringComparison.Ordinal))
{
await HandleCommandAsync(message, commandText, ct);
break;
}
if (message.Text is not null)
{
await rescheduleTimeInputHandler.TryHandleAsync(message, ct);
}
// Non-command text messages — check for reschedule time input
case { Message: { Text: { } } message } when !message.Text!.StartsWith('/'):
await rescheduleTimeInputHandler.TryHandleAsync(message, ct);
break;
}
}
/// <summary>
/// Handles router-level draft-control callbacks ("resume", "reset"). Returns true
/// if the update was consumed and the wizard should be skipped. The wizard still
/// owns "cancel" and "create".
/// </summary>
private async Task<bool> TryHandleDraftControlCallbackAsync(
Update update, WizardDraft draft, CancellationToken ct)
{
if (update.CallbackQuery is not { Data: { } data, Message: { } cbMessage, From: { } cbFrom })
return false;
switch (data)
{
case WizardControlCallbacks.Resume:
// Re-render the current step of the existing draft. We answer the
// callback here because the wizard will not be called.
var (text, kb) = WizardStep.Render(draft, LoadPayload(draft));
await bot.EditMessageText(
chatId: cbMessage.Chat.Id,
messageId: cbMessage.MessageId,
text: text,
replyMarkup: kb,
cancellationToken: ct);
await bot.AnswerCallbackQuery(update.CallbackQuery.Id, cancellationToken: ct);
return true;
case WizardControlCallbacks.Reset:
// Delete the existing draft and start a fresh one. The wizard is
// bypassed entirely because the active draft is now gone.
await drafts.DeleteAsync(draft.Id, ct);
await bot.AnswerCallbackQuery(update.CallbackQuery.Id, cancellationToken: ct);
var newDraft = await createSessionHandler.StartWizardAsync(
SyntheticStartMessage(cbMessage.Chat.Id, cbMessage.MessageThreadId, cbFrom.Id), ct);
if (newDraft is null)
{
// Race: another wizard just started for the same owner. The
// user can simply re-run /newsession. We don't loop.
await bot.SendMessage(
chatId: cbMessage.Chat.Id,
text: "Не удалось начать заново — попробуйте ещё раз через /newsession.",
cancellationToken: ct);
}
return true;
}
return false;
}
/// <summary>
/// Build a synthetic <see cref="Message"/> carrying just the fields
/// <see cref="CreateSessionHandler.StartWizardAsync"/> reads (chat, thread, from).
/// </summary>
private static Message SyntheticStartMessage(long chatId, int? messageThreadId, long fromId) => new()
{
Chat = new Chat { Id = chatId },
MessageThreadId = messageThreadId,
From = new User { Id = fromId },
};
private static WizardPayload LoadPayload(WizardDraft draft) =>
SharedWizard.LoadPayload(draft);
internal static string GetCommandText(Message message)
=> (message.Text ?? message.Caption ?? string.Empty).TrimStart();
/// <summary>
/// Extracts the (chat, thread, owner) triple from an update for wizard lookups.
/// Returns false for updates that carry no usable origin (e.g. inline queries).
/// </summary>
private static bool TryGetWizardContext(Update update, out long chatId, out int? messageThreadId, out string ownerId)
{
chatId = 0;
messageThreadId = null;
ownerId = string.Empty;
switch (update)
{
case { Message: { From: not null, Chat: { } chat } msg }:
chatId = chat.Id;
messageThreadId = msg.MessageThreadId;
ownerId = msg.From!.Id.ToString(CultureInfo.InvariantCulture);
return true;
case { CallbackQuery: { From: not null, Message: { Chat: { } cbmChat } } cb }:
chatId = cbmChat.Id;
messageThreadId = cb.Message?.MessageThreadId;
ownerId = cb.From!.Id.ToString(CultureInfo.InvariantCulture);
return true;
case { CallbackQuery: { From: not null } cb2 }:
// Callback arrived without a message (e.g. from a Mini App). No chat
// context → wizard cannot run on this update.
ownerId = cb2.From!.Id.ToString(CultureInfo.InvariantCulture);
return false;
default:
return false;
}
}
private async Task HandleCallbackQueryAsync(CallbackQuery query, CancellationToken ct)
{
if (query.Data is not { } data || query.Message is not { } message)
@@ -56,22 +209,39 @@ public sealed class UpdateRouter(
var parts = data.Split(':', 3);
var action = parts[0];
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);
if (action == "join_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var joinSessionId))
{
var command = new JoinSessionCommand(
SessionId: joinSessionId,
TelegramUserId: query.From.Id,
DisplayName: query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"),
TelegramUsername: query.From.Username,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageId: message.MessageId);
User: user,
InteractionId: query.Id,
Group: group,
ScheduleMessage: scheduleMessage);
await joinSessionHandler.HandleAsync(command, ct);
return;
}
if (action == "leave_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var leaveSessionId))
{
var command = new LeaveSessionCommand(
SessionId: leaveSessionId,
User: user,
InteractionId: query.Id,
Group: group,
ScheduleMessage: scheduleMessage);
await leaveSessionHandler.HandleAsync(command, ct);
return;
}
if (action == "cancel_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var cancelSessionId))
{
var command = new CancelSessionCommand(
@@ -79,12 +249,26 @@ public sealed class UpdateRouter(
TelegramUserId: query.From.Id,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageThreadId: message.MessageThreadId,
MessageId: message.MessageId);
await cancelSessionHandler.HandleAsync(command, ct);
return;
}
if (action == "promote_waitlist" && parts.Length >= 2 && Guid.TryParse(parts[1], out var promoteSessionId))
{
var command = new PromoteWaitlistedPlayerCommand(
SessionId: promoteSessionId,
TelegramUserId: query.From.Id,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageId: message.MessageId);
await promoteWaitlistedPlayerHandler.HandleAsync(command, ct);
return;
}
if (action == "delete_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var deleteSessionId))
{
var command = new DeleteSessionCommand(
@@ -105,21 +289,17 @@ public sealed class UpdateRouter(
TelegramUserId: query.From.Id,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageThreadId: message.MessageThreadId,
MessageId: message.MessageId);
await initiateRescheduleHandler.HandleAsync(command, ct);
return;
}
if (action == "reschedule_vote" && parts.Length >= 3 && Guid.TryParse(parts[2], out var proposalId))
if (action == "reschedule_vote" && parts.Length >= 2 && Guid.TryParse(parts[1], out var optionId))
{
var vote = parts[1]; // "yes" or "no"
if (vote is not ("yes" or "no"))
return;
var command = new HandleRescheduleVoteCommand(
ProposalId: proposalId,
Vote: vote,
OptionId: optionId,
TelegramUserId: query.From.Id,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
@@ -146,11 +326,11 @@ public sealed class UpdateRouter(
var command = new HandleRsvpCommand(
SessionId: sessionId,
TelegramUserId: query.From.Id,
User: user,
Status: status,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageId: message.MessageId);
InteractionId: query.Id,
Group: group,
ConfirmationMessage: scheduleMessage);
await rsvpHandler.HandleAsync(command, ct);
}
@@ -165,14 +345,11 @@ public sealed class UpdateRouter(
switch (command)
{
case "/start":
await bot.SendMessage(
chatId: message.Chat.Id,
text: "GM-Relay Bot ready. Use /help for commands.",
cancellationToken: ct);
await SendStartMessageAsync(message, ct);
break;
case "/newsession":
await createSessionHandler.HandleAsync(message, ct);
await HandleNewSessionCommandAsync(message, ct);
break;
case "/listsessions":
@@ -192,9 +369,17 @@ public sealed class UpdateRouter(
/newsession
Название: My Game
Время: 15.05.2026 19:30
Мест: 4
Ссылка: https://link
Картинка: https://cover
Для регулярного расписания можно указать одну дату:
Игр: 4
Интервал: 7
/listsessions список предстоящих сессий
Для owner/co-GM /listsessions показывает кнопки отмены, переноса, удаления и повышения из листа ожидания.
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
/help эта справка
""",
cancellationToken: ct);
@@ -206,4 +391,74 @@ public sealed class UpdateRouter(
break;
}
}
private async Task HandleNewSessionCommandAsync(Message message, CancellationToken ct)
{
// Try to start a fresh wizard. StartWizardAsync returns null when a
// non-expired draft already exists for this (chat, thread, owner).
var draft = await createSessionHandler.StartWizardAsync(message, ct);
if (draft is not null)
{
// New draft was created and its first step has been rendered.
return;
}
// Existing draft. Look it up so we can describe the current step and offer
// a Continue / Start over / Cancel menu.
var existing = await createSessionHandler.TryResumeAsync(message, ct);
if (existing is null)
{
// Race: the draft expired between the two calls (or the user lacks
// ownership metadata). Fall back to silently starting a new wizard.
await createSessionHandler.StartWizardAsync(message, ct);
return;
}
await bot.SendMessage(
chatId: message.Chat.Id,
text: "У вас уже есть незавершённый мастер. Продолжить?",
replyMarkup: ContinueResetCancelKeyboard(),
cancellationToken: ct);
}
private InlineKeyboardMarkup ContinueResetCancelKeyboard() => new(new[]
{
// "Продолжить" re-renders the existing draft's current step (router-level).
// "Начать заново" deletes the existing draft and creates a fresh one (router-level).
// "Отмена" delegates to the wizard's normal cancel handler.
new[] { InlineKeyboardButton.WithCallbackData("➡️ Продолжить", WizardControlCallbacks.Resume) },
new[] { InlineKeyboardButton.WithCallbackData("🔁 Начать заново", WizardControlCallbacks.Reset) },
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
});
private async Task SendStartMessageAsync(Message message, CancellationToken ct)
{
var miniAppUrl = configuration["Telegram:MiniAppUrl"];
if (string.IsNullOrWhiteSpace(miniAppUrl))
{
await bot.SendMessage(
chatId: message.Chat.Id,
text: "GM-Relay Bot ready. Use /help for commands.",
cancellationToken: ct);
return;
}
await bot.SendMessage(
chatId: message.Chat.Id,
text: "GM-Relay Bot ready. Откройте dashboard внутри Telegram или используйте /help для команд.",
replyMarkup: new InlineKeyboardMarkup(
InlineKeyboardButton.WithWebApp("Открыть dashboard", new WebAppInfo(miniAppUrl))),
cancellationToken: ct);
}
}
/// <summary>
/// Router-level callback data for the Continue / Start over / Cancel menu shown
/// when /newsession detects an existing wizard draft. Distinct from
/// <see cref="WizardCallbackData"/> which is parsed and consumed by the wizard itself.
/// </summary>
internal static class WizardControlCallbacks
{
public const string Resume = "wizard:resume";
public const string Reset = "wizard:reset";
}
@@ -0,0 +1,16 @@
-- Add per-session seat limits and participant waitlist support.
ALTER TABLE sessions
ADD COLUMN max_players INTEGER,
ADD CONSTRAINT ck_sessions_max_players CHECK (max_players IS NULL OR max_players > 0);
ALTER TABLE session_participants
ADD COLUMN registration_status VARCHAR(50) NOT NULL DEFAULT 'Active'
CHECK (registration_status IN ('Active', 'Waitlisted')),
ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT now();
CREATE INDEX ix_session_participants_session_registration_status
ON session_participants (session_id, registration_status);
CREATE INDEX ix_session_participants_waitlist_order
ON session_participants (session_id, created_at, id)
WHERE registration_status = 'Waitlisted';
@@ -0,0 +1,8 @@
ALTER TABLE sessions
ADD COLUMN notification_mode VARCHAR(50) NOT NULL DEFAULT 'GroupAndDirect'
CHECK (notification_mode IN ('GroupAndDirect', 'GroupOnly')),
ADD COLUMN one_hour_reminder_processed_at TIMESTAMPTZ;
CREATE INDEX ix_sessions_one_hour_reminders ON sessions (scheduled_at)
WHERE status IN ('Confirmed', 'ConfirmationSent')
AND one_hour_reminder_processed_at IS NULL;
@@ -0,0 +1,26 @@
-- Add explicit owner/co-GM management roles for each Telegram group.
INSERT INTO players (telegram_id, display_name)
SELECT DISTINCT gg.gm_telegram_id,
'GM ' || gg.gm_telegram_id::text
FROM game_groups gg
ON CONFLICT (telegram_id) DO NOTHING;
CREATE TABLE group_managers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
role VARCHAR(50) NOT NULL CHECK (role IN ('Owner', 'CoGm')),
added_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (group_id, player_id)
);
INSERT INTO group_managers (group_id, player_id, role)
SELECT gg.id, p.id, 'Owner'
FROM game_groups gg
JOIN players p ON p.telegram_id = gg.gm_telegram_id
ON CONFLICT (group_id, player_id) DO NOTHING;
CREATE INDEX ix_group_managers_group_role ON group_managers (group_id, role);
CREATE INDEX ix_group_managers_player ON group_managers (player_id);
@@ -0,0 +1,35 @@
-- Multi-option reschedule voting with a deadline.
ALTER TABLE reschedule_proposals
ADD COLUMN voting_deadline_at TIMESTAMPTZ,
ADD COLUMN selected_option_id UUID;
CREATE TABLE reschedule_options (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
proposal_id UUID NOT NULL REFERENCES reschedule_proposals(id) ON DELETE CASCADE,
proposed_at TIMESTAMPTZ NOT NULL,
display_order INTEGER NOT NULL CHECK (display_order BETWEEN 1 AND 3),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (proposal_id, id),
UNIQUE (proposal_id, display_order),
UNIQUE (proposal_id, proposed_at)
);
CREATE TABLE reschedule_option_votes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
proposal_id UUID NOT NULL REFERENCES reschedule_proposals(id) ON DELETE CASCADE,
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
option_id UUID NOT NULL,
voted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (proposal_id, player_id),
FOREIGN KEY (proposal_id, option_id)
REFERENCES reschedule_options(proposal_id, id)
ON DELETE CASCADE
);
CREATE INDEX ix_reschedule_voting_deadline
ON reschedule_proposals (voting_deadline_at)
WHERE status = 'Voting';
CREATE INDEX ix_reschedule_option_votes_option
ON reschedule_option_votes (option_id);
@@ -0,0 +1,17 @@
CREATE TABLE campaign_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
title VARCHAR(500) NOT NULL,
join_link TEXT NOT NULL,
session_count INTEGER NOT NULL CHECK (session_count BETWEEN 1 AND 52),
interval_days INTEGER NOT NULL CHECK (interval_days BETWEEN 1 AND 365),
max_players INTEGER CHECK (max_players IS NULL OR max_players > 0),
notification_mode VARCHAR(32) NOT NULL DEFAULT 'GroupAndDirect'
CHECK (notification_mode IN ('GroupAndDirect', 'GroupOnly')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (group_id, name)
);
CREATE INDEX ix_campaign_templates_group ON campaign_templates (group_id, created_at DESC);
@@ -0,0 +1,11 @@
CREATE TABLE calendar_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token TEXT UNIQUE NOT NULL,
user_telegram_id BIGINT NOT NULL,
group_id UUID REFERENCES game_groups(id) ON DELETE CASCADE,
filter_type SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ
);
CREATE INDEX ix_calendar_subscriptions_user_telegram_id ON calendar_subscriptions (user_telegram_id);
@@ -0,0 +1,66 @@
-- =============================================================
-- Attendance statistics view for GM analytics
-- Returns per-player aggregated metrics for a given game group.
-- NOTE: waitlist count reflects CURRENT registration_status only.
-- Full historical waitlist tracking will come with #15.
-- =============================================================
CREATE OR REPLACE FUNCTION get_group_attendance_stats(p_group_id UUID)
RETURNS TABLE (
player_id UUID,
display_name VARCHAR,
telegram_username VARCHAR,
total_sessions BIGINT,
confirmed_count BIGINT,
declined_count BIGINT,
no_response_count BIGINT,
waitlisted_count BIGINT,
cancellation_affected_count BIGINT,
attendance_rate NUMERIC
) AS $$
BEGIN
RETURN QUERY
WITH player_sessions AS (
SELECT
sp.player_id,
s.id AS session_id,
sp.rsvp_status,
sp.registration_status,
s.status AS session_status,
s.scheduled_at
FROM session_participants sp
JOIN sessions s ON s.id = sp.session_id
WHERE s.group_id = p_group_id
),
player_totals AS (
SELECT
ps.player_id,
COUNT(*) FILTER (WHERE ps.session_status <> 'Cancelled') AS total_sessions,
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Confirmed' AND ps.session_status <> 'Cancelled') AS confirmed_count,
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Declined' AND ps.session_status <> 'Cancelled') AS declined_count,
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Pending' AND ps.scheduled_at < NOW() AND ps.session_status <> 'Cancelled') AS no_response_count,
COUNT(*) FILTER (WHERE ps.registration_status = 'Waitlisted' AND ps.session_status <> 'Cancelled') AS waitlisted_count,
COUNT(*) FILTER (WHERE ps.session_status = 'Cancelled') AS cancellation_affected_count
FROM player_sessions ps
GROUP BY ps.player_id
)
SELECT
pt.player_id,
p.display_name,
COALESCE(p.external_username, p.telegram_username) AS telegram_username,
pt.total_sessions,
pt.confirmed_count,
pt.declined_count,
pt.no_response_count,
pt.waitlisted_count,
pt.cancellation_affected_count,
ROUND(
100.0 * pt.confirmed_count
/ NULLIF(pt.total_sessions, 0),
1
) AS attendance_rate
FROM player_totals pt
JOIN players p ON p.id = pt.player_id
ORDER BY pt.confirmed_count DESC, pt.total_sessions DESC;
END;
$$ LANGUAGE plpgsql STABLE;
@@ -0,0 +1,16 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE session_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
actor_telegram_id BIGINT NOT NULL,
actor_name VARCHAR(255) NOT NULL,
change_type VARCHAR(50) NOT NULL
CHECK (change_type IN ('Title','Time','Link','MaxPlayers','Status','WaitlistPromote','PlayerRemoved','BatchRescheduled','Cancelled')),
old_value TEXT,
new_value TEXT,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_session_audit_log_session_id ON session_audit_log(session_id);
CREATE INDEX ix_session_audit_log_changed_at ON session_audit_log(changed_at);
@@ -0,0 +1,13 @@
ALTER TABLE sessions
ADD COLUMN confirmation_sent_at TIMESTAMPTZ;
-- Update existing ConfirmationSent sessions to have a sentinel value
-- so they don't get re-processed after migration
UPDATE sessions
SET confirmation_sent_at = now()
WHERE status = 'ConfirmationSent';
-- Partial index for efficient T-24h query
CREATE INDEX ix_sessions_confirmation_reminders ON sessions (scheduled_at)
WHERE status = 'Planned'
AND confirmation_sent_at IS NULL;
@@ -0,0 +1,6 @@
ALTER TABLE sessions
ADD COLUMN topic_created_by_bot BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE sessions
SET topic_created_by_bot = TRUE
WHERE thread_id IS NOT NULL;
@@ -0,0 +1,119 @@
-- =============================================================
-- V016: Add platform identity columns and platform_messages table
-- =============================================================
-- Scope: Prepare schema for multi-platform support (Discord, etc).
-- Legacy telegram_* columns are retained for backward compatibility.
-- =============================================================
-- -- Players: platform-agnostic identity
ALTER TABLE players
ADD COLUMN platform VARCHAR(50),
ADD COLUMN external_user_id VARCHAR(255),
ADD COLUMN external_username VARCHAR(255);
CREATE UNIQUE INDEX ix_players_platform_external_user_id
ON players (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL;
-- -- Game groups: platform-agnostic identity
ALTER TABLE game_groups
ADD COLUMN platform VARCHAR(50),
ADD COLUMN external_group_id VARCHAR(255),
ADD COLUMN external_channel_id VARCHAR(255);
CREATE UNIQUE INDEX ix_game_groups_platform_external_group_id
ON game_groups (platform, external_group_id)
WHERE platform IS NOT NULL AND external_group_id IS NOT NULL;
-- -- Backfill existing Telegram data
UPDATE players
SET platform = 'Telegram',
external_user_id = telegram_id::TEXT,
external_username = telegram_username
WHERE platform IS NULL;
UPDATE game_groups
SET platform = 'Telegram',
external_group_id = telegram_chat_id::TEXT
WHERE platform IS NULL;
-- -- Platform messages: store per-platform message references
CREATE TABLE platform_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
platform VARCHAR(50) NOT NULL,
group_id UUID REFERENCES game_groups(id) ON DELETE CASCADE,
batch_id UUID,
session_id UUID REFERENCES sessions(id) ON DELETE CASCADE,
external_channel_id VARCHAR(255),
external_thread_id VARCHAR(255),
external_message_id VARCHAR(255) NOT NULL,
purpose VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_platform_messages_group_id ON platform_messages(group_id);
CREATE INDEX ix_platform_messages_batch_id ON platform_messages(batch_id);
CREATE INDEX ix_platform_messages_session_id ON platform_messages(session_id);
CREATE INDEX ix_platform_messages_platform_message
ON platform_messages (platform, external_message_id);
-- -- Recreate attendance stats function for new columns (prod back-compat)
CREATE OR REPLACE FUNCTION get_group_attendance_stats(p_group_id UUID)
RETURNS TABLE (
player_id UUID,
display_name VARCHAR,
telegram_username VARCHAR,
total_sessions BIGINT,
confirmed_count BIGINT,
declined_count BIGINT,
no_response_count BIGINT,
waitlisted_count BIGINT,
cancellation_affected_count BIGINT,
attendance_rate NUMERIC
) AS $$
BEGIN
RETURN QUERY
WITH player_sessions AS (
SELECT
sp.player_id,
s.id AS session_id,
sp.rsvp_status,
sp.registration_status,
s.status AS session_status,
s.scheduled_at
FROM session_participants sp
JOIN sessions s ON s.id = sp.session_id
WHERE s.group_id = p_group_id
),
player_totals AS (
SELECT
ps.player_id,
COUNT(*) FILTER (WHERE ps.session_status <> 'Cancelled') AS total_sessions,
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Confirmed' AND ps.session_status <> 'Cancelled') AS confirmed_count,
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Declined' AND ps.session_status <> 'Cancelled') AS declined_count,
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Pending' AND ps.scheduled_at < NOW() AND ps.session_status <> 'Cancelled') AS no_response_count,
COUNT(*) FILTER (WHERE ps.registration_status = 'Waitlisted' AND ps.session_status <> 'Cancelled') AS waitlisted_count,
COUNT(*) FILTER (WHERE ps.session_status = 'Cancelled') AS cancellation_affected_count
FROM player_sessions ps
GROUP BY ps.player_id
)
SELECT
pt.player_id,
p.display_name,
COALESCE(p.external_username, p.telegram_username) AS telegram_username,
pt.total_sessions,
pt.confirmed_count,
pt.declined_count,
pt.no_response_count,
pt.waitlisted_count,
pt.cancellation_affected_count,
ROUND(
100.0 * pt.confirmed_count
/ NULLIF(pt.total_sessions, 0),
1
) AS attendance_rate
FROM player_totals pt
JOIN players p ON p.id = pt.player_id
ORDER BY pt.confirmed_count DESC, pt.total_sessions DESC;
END;
$$ LANGUAGE plpgsql STABLE;
@@ -0,0 +1,9 @@
-- =============================================================
-- 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;
@@ -0,0 +1,19 @@
-- =============================================================
-- V018: Add platform columns to reschedule_proposals
-- =============================================================
-- Add platform columns to reschedule_proposals to support Discord reschedule voting.
-- proposed_by is made nullable so Discord proposals can leave it NULL
-- (Discord snowflakes don't fit in BIGINT safely).
-- =============================================================
ALTER TABLE reschedule_proposals
ALTER COLUMN proposed_by DROP NOT NULL;
ALTER TABLE reschedule_proposals
ADD COLUMN source_platform VARCHAR(50),
ADD COLUMN proposed_by_external_user_id VARCHAR(255);
UPDATE reschedule_proposals
SET source_platform = 'Telegram',
proposed_by_external_user_id = proposed_by::TEXT
WHERE source_platform IS NULL;
@@ -0,0 +1,18 @@
-- =============================================================
-- V019: Rename session_audit_log.actor_telegram_id to actor_external_user_id
-- =============================================================
-- Scope: Support platform-agnostic audit log identity.
-- =============================================================
ALTER TABLE session_audit_log
ADD COLUMN actor_external_user_id VARCHAR(255);
UPDATE session_audit_log
SET actor_external_user_id = actor_telegram_id::TEXT
WHERE actor_external_user_id IS NULL;
ALTER TABLE session_audit_log
ALTER COLUMN actor_external_user_id SET NOT NULL;
ALTER TABLE session_audit_log
DROP COLUMN actor_telegram_id;
@@ -0,0 +1,37 @@
-- =============================================================
-- V020: Player identity linking for unified multi-platform accounts
-- =============================================================
-- Scope: Allow linking multiple platform identities (Telegram, Discord)
-- to a single "primary" player account. All group/session permissions
-- resolve through the effective (primary) player id.
-- =============================================================
-- player_links: secondary player → primary player (1:1 on secondary)
CREATE TABLE player_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
primary_player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
secondary_player_id UUID NOT NULL UNIQUE REFERENCES players(id) ON DELETE CASCADE,
linked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
linked_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,
-- Prevent self-linking at the DB level
CONSTRAINT no_self_link CHECK (primary_player_id <> secondary_player_id)
);
CREATE INDEX ix_player_links_primary_player_id
ON player_links(primary_player_id);
-- identity_audit_log: security-sensitive link/unlink actions
CREATE TABLE identity_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
action VARCHAR(50) NOT NULL, -- 'link', 'unlink', 'link_attempt_conflict'
target_platform VARCHAR(50),
target_external_user_id VARCHAR(255),
performed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
performed_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL
);
CREATE INDEX ix_identity_audit_log_player_id
ON identity_audit_log(player_id);
CREATE INDEX ix_identity_audit_log_performed_at
ON identity_audit_log(performed_at DESC);
@@ -0,0 +1,8 @@
-- =============================================================
-- V021: Add avatar_url column to players table
-- =============================================================
-- Scope: Support storing avatar URLs for Discord and other platforms.
-- =============================================================
ALTER TABLE players
ADD COLUMN avatar_url VARCHAR(500);
@@ -0,0 +1,16 @@
-- =============================================================
-- V022: Fix incorrectly oriented player_links for Discord↔Telegram
-- =============================================================
-- Scope: Reverse player_links where Discord was incorrectly made primary
-- and Telegram secondary. Telegram (with historical group/session data)
-- must always be the primary account.
-- =============================================================
UPDATE player_links pl
SET primary_player_id = pl.secondary_player_id,
secondary_player_id = pl.primary_player_id
FROM players p1, players p2
WHERE pl.primary_player_id = p1.id
AND pl.secondary_player_id = p2.id
AND p1.platform = 'Discord'
AND p2.platform = 'Telegram';
@@ -0,0 +1,14 @@
-- =============================================================
-- V023: Make legacy Telegram columns nullable for multi-platform
-- =============================================================
-- Scope: Allow Discord (and future platforms) to create players
-- and game_groups without legacy telegram_* values.
-- Existing Telegram data was backfilled in V016.
-- =============================================================
ALTER TABLE game_groups
ALTER COLUMN telegram_chat_id DROP NOT NULL,
ALTER COLUMN gm_telegram_id DROP NOT NULL;
ALTER TABLE players
ALTER COLUMN telegram_id DROP NOT NULL;
@@ -0,0 +1,41 @@
-- =============================================================
-- V024: Deprecate legacy Telegram-specific columns
-- =============================================================
-- Scope: Complete platform migration by backfilling any remaining
-- external_* gaps and officially deprecating telegram_* columns.
-- No columns are dropped — rollback-safe.
-- =============================================================
-- 1. Backfill players platform identity (safeguard for any rows missed in V016)
UPDATE players
SET platform = 'Telegram',
external_user_id = telegram_id::TEXT,
external_username = telegram_username
WHERE platform IS NULL;
-- 2. Backfill game_groups platform identity (safeguard for any rows missed in V016)
UPDATE game_groups
SET platform = 'Telegram',
external_group_id = telegram_chat_id::TEXT
WHERE platform IS NULL;
-- 3. Add platform identity to calendar_subscriptions
ALTER TABLE calendar_subscriptions
ADD COLUMN user_platform VARCHAR(50),
ADD COLUMN user_external_id VARCHAR(255);
UPDATE calendar_subscriptions
SET user_external_id = user_telegram_id::TEXT,
user_platform = 'Telegram'
WHERE user_platform IS NULL;
-- 4. Migrate calendar subscription index
DROP INDEX IF EXISTS ix_calendar_subscriptions_user_telegram_id;
CREATE INDEX ix_calendar_subscriptions_user_external_id ON calendar_subscriptions (user_external_id);
-- 5. Deprecation comments on legacy columns
COMMENT ON COLUMN players.telegram_id IS 'DEPRECATED: use platform + external_user_id';
COMMENT ON COLUMN players.telegram_username IS 'DEPRECATED: use external_username';
COMMENT ON COLUMN game_groups.telegram_chat_id IS 'DEPRECATED: use platform + external_group_id';
COMMENT ON COLUMN game_groups.gm_telegram_id IS 'DEPRECATED: group ownership is tracked in group_managers';
COMMENT ON COLUMN calendar_subscriptions.user_telegram_id IS 'DEPRECATED: use user_platform + user_external_id';
@@ -0,0 +1,11 @@
-- =============================================================
-- V025: Backfill proposed_by_external_user_id for Telegram proposals
-- =============================================================
-- Scope: Ensure all reschedule_proposals have proposed_by_external_user_id
-- populated so that InitiateRescheduleHandler can stop writing proposed_by.
-- =============================================================
UPDATE reschedule_proposals
SET proposed_by_external_user_id = proposed_by::TEXT
WHERE proposed_by_external_user_id IS NULL
AND proposed_by IS NOT NULL;
@@ -0,0 +1,17 @@
-- Public club pages and read-only schedule publication controls.
ALTER TABLE game_groups
ADD COLUMN public_slug VARCHAR(120),
ADD COLUMN public_schedule_enabled BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN public_schedule_updated_at TIMESTAMPTZ;
ALTER TABLE sessions
ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT false;
CREATE UNIQUE INDEX ux_game_groups_public_slug
ON game_groups (lower(public_slug))
WHERE public_slug IS NOT NULL;
CREATE INDEX ix_sessions_public_schedule
ON sessions (group_id, scheduled_at)
WHERE is_public = true AND status <> 'Cancelled';
@@ -0,0 +1,14 @@
-- Showcase fields for game catalog / public session browsing.
ALTER TABLE sessions
ADD COLUMN is_one_shot BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN system VARCHAR(50),
ADD COLUMN description TEXT,
ADD COLUMN cover_image_url TEXT,
ADD COLUMN duration_minutes INTEGER,
ADD COLUMN format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')),
ADD COLUMN allow_direct_registration BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX ix_sessions_showcase
ON sessions (scheduled_at, system, is_one_shot, format)
WHERE is_public = true AND status <> 'Cancelled';
@@ -0,0 +1,20 @@
-- Public GM profiles for catalog and club trust pages.
CREATE TABLE master_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
player_id UUID NOT NULL UNIQUE REFERENCES players(id) ON DELETE CASCADE,
public_slug VARCHAR(120),
is_public BOOLEAN NOT NULL DEFAULT false,
display_name VARCHAR(255) NOT NULL,
bio TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX ux_master_profiles_public_slug
ON master_profiles (lower(public_slug))
WHERE public_slug IS NOT NULL;
CREATE INDEX ix_master_profiles_public
ON master_profiles (lower(public_slug))
WHERE is_public = true AND public_slug IS NOT NULL;
@@ -0,0 +1,261 @@
-- Completed adventure portfolio cards with linked sessions, masters, and moderated reviews.
CREATE TABLE portfolio_games (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
public_slug VARCHAR(160),
title VARCHAR(255) NOT NULL,
description TEXT,
cover_storage_key TEXT,
system VARCHAR(50),
format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')),
completed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
is_public BOOLEAN NOT NULL DEFAULT false,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (
NOT is_public
OR (
public_slug IS NOT NULL
AND description IS NOT NULL
AND cover_storage_key IS NOT NULL
AND published_at IS NOT NULL
)
)
);
CREATE UNIQUE INDEX ux_portfolio_games_public_slug
ON portfolio_games (lower(public_slug))
WHERE public_slug IS NOT NULL;
CREATE INDEX ix_portfolio_games_group
ON portfolio_games (group_id, completed_at DESC);
CREATE INDEX ix_portfolio_games_public
ON portfolio_games (completed_at DESC)
WHERE is_public = true;
CREATE TABLE portfolio_game_sessions (
portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
PRIMARY KEY (portfolio_game_id, session_id),
UNIQUE (session_id)
);
CREATE TABLE portfolio_game_masters (
portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
PRIMARY KEY (portfolio_game_id, player_id)
);
CREATE INDEX ix_portfolio_game_masters_player
ON portfolio_game_masters (player_id, portfolio_game_id);
CREATE FUNCTION lock_portfolio_publication_mutation()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM pg_advisory_xact_lock(20260530, 108);
RETURN NULL;
END;
$$;
CREATE TRIGGER trg_portfolio_games_lock_publication_mutation
BEFORE INSERT OR DELETE OR UPDATE OF is_public ON portfolio_games
FOR EACH STATEMENT
EXECUTE FUNCTION lock_portfolio_publication_mutation();
CREATE TRIGGER trg_portfolio_game_sessions_lock_publication_mutation
BEFORE INSERT OR DELETE OR UPDATE ON portfolio_game_sessions
FOR EACH STATEMENT
EXECUTE FUNCTION lock_portfolio_publication_mutation();
CREATE TRIGGER trg_portfolio_game_masters_lock_publication_mutation
BEFORE INSERT OR DELETE OR UPDATE ON portfolio_game_masters
FOR EACH STATEMENT
EXECUTE FUNCTION lock_portfolio_publication_mutation();
CREATE TRIGGER trg_sessions_lock_portfolio_publication_mutation
BEFORE DELETE OR UPDATE OF scheduled_at ON sessions
FOR EACH STATEMENT
EXECUTE FUNCTION lock_portfolio_publication_mutation();
CREATE TRIGGER trg_game_groups_lock_portfolio_publication_mutation_before_delete
BEFORE DELETE ON game_groups
FOR EACH STATEMENT
EXECUTE FUNCTION lock_portfolio_publication_mutation();
CREATE TRIGGER trg_players_lock_portfolio_publication_mutation_before_delete
BEFORE DELETE ON players
FOR EACH STATEMENT
EXECUTE FUNCTION lock_portfolio_publication_mutation();
CREATE FUNCTION validate_public_portfolio_game_required_links()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
target_portfolio_game_id UUID;
target_portfolio_game_ids UUID[];
BEGIN
PERFORM pg_advisory_xact_lock(20260530, 108);
IF TG_TABLE_NAME = 'portfolio_games' THEN
target_portfolio_game_ids := ARRAY[NEW.id];
ELSIF TG_OP = 'DELETE' THEN
target_portfolio_game_ids := ARRAY[OLD.portfolio_game_id];
ELSIF TG_OP = 'INSERT' THEN
target_portfolio_game_ids := ARRAY[NEW.portfolio_game_id];
ELSE
target_portfolio_game_ids := ARRAY[OLD.portfolio_game_id, NEW.portfolio_game_id];
END IF;
IF current_setting('transaction_isolation') <> 'read committed' THEN
RAISE EXCEPTION
'portfolio publication validation requires read committed isolation'
USING ERRCODE = '0A000';
END IF;
SELECT pg.id
INTO target_portfolio_game_id
FROM portfolio_games pg
WHERE pg.id = ANY(target_portfolio_game_ids)
AND pg.is_public = true
AND (
NOT EXISTS (
SELECT 1
FROM portfolio_game_sessions pgs
WHERE pgs.portfolio_game_id = pg.id
)
OR EXISTS (
SELECT 1
FROM portfolio_game_sessions pgs
JOIN sessions s ON s.id = pgs.session_id
WHERE pgs.portfolio_game_id = pg.id
AND s.scheduled_at >= now()
)
OR NOT EXISTS (
SELECT 1
FROM portfolio_game_masters pgm
WHERE pgm.portfolio_game_id = pg.id
)
)
LIMIT 1;
IF target_portfolio_game_id IS NOT NULL THEN
RAISE EXCEPTION
'published portfolio game % must have at least one linked session and at least one linked master',
target_portfolio_game_id
USING ERRCODE = '23514';
END IF;
RETURN NULL;
END;
$$;
CREATE FUNCTION unpublish_public_portfolio_games_for_future_session()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
final_scheduled_at TIMESTAMPTZ;
BEGIN
SELECT s.scheduled_at
INTO final_scheduled_at
FROM sessions s
WHERE s.id = NEW.id;
IF final_scheduled_at >= now() THEN
IF current_setting('transaction_isolation') <> 'read committed' THEN
RAISE EXCEPTION
'portfolio future reschedule requires read committed isolation'
USING ERRCODE = '0A000';
END IF;
PERFORM pg.id
FROM portfolio_games pg
WHERE EXISTS (
SELECT 1
FROM portfolio_game_sessions pgs
JOIN sessions s ON s.id = pgs.session_id
WHERE pgs.portfolio_game_id = pg.id
AND s.scheduled_at >= now()
)
ORDER BY pg.id
FOR UPDATE OF pg;
PERFORM pg_advisory_xact_lock(20260530, 108);
UPDATE portfolio_games pg
SET is_public = false,
updated_at = now()
WHERE pg.is_public = true
AND EXISTS (
SELECT 1
FROM portfolio_game_sessions pgs
JOIN sessions s ON s.id = pgs.session_id
WHERE pgs.portfolio_game_id = pg.id
AND s.scheduled_at >= now()
);
END IF;
RETURN NULL;
END;
$$;
CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule
AFTER UPDATE OF scheduled_at ON sessions
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION unpublish_public_portfolio_games_for_future_session();
CREATE CONSTRAINT TRIGGER trg_portfolio_games_validate_required_links
AFTER INSERT OR UPDATE OF is_public ON portfolio_games
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links
AFTER INSERT OR DELETE OR UPDATE OF portfolio_game_id, session_id ON portfolio_game_sessions
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links
AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
CREATE TABLE portfolio_game_reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
author_player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
author_display_name VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
publication_consent_at TIMESTAMPTZ NOT NULL,
moderation_status VARCHAR(20) NOT NULL DEFAULT 'Pending'
CHECK (moderation_status IN ('Pending', 'Approved', 'Rejected', 'Hidden')),
moderated_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,
moderated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (portfolio_game_id, author_player_id)
);
CREATE INDEX ix_portfolio_game_reviews_author
ON portfolio_game_reviews (author_player_id);
CREATE INDEX ix_portfolio_game_reviews_moderator
ON portfolio_game_reviews (moderated_by_player_id)
WHERE moderated_by_player_id IS NOT NULL;
CREATE INDEX ix_portfolio_game_reviews_public
ON portfolio_game_reviews (portfolio_game_id, created_at DESC)
WHERE moderation_status = 'Approved' AND publication_consent_at IS NOT NULL;
CREATE INDEX ix_portfolio_game_reviews_pending
ON portfolio_game_reviews (portfolio_game_id, created_at DESC)
WHERE moderation_status = 'Pending';
@@ -0,0 +1,66 @@
-- V030: Private club showcases. Adds club_memberships (member access control)
-- and replaces sessions.is_public with a 4-state publication_mode enum.
-- Backfills existing data: is_public=true → 'Both', is_public=false → 'None'.
-- portfolio_games gains the same enum (default 'Both' for pre-V030 rows).
-- 1. club_memberships
CREATE TABLE club_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'Pending'
CHECK (status IN ('Pending', 'Active', 'Rejected', 'Left')),
role VARCHAR(20) NOT NULL DEFAULT 'Member'
CHECK (role IN ('Member')),
message TEXT,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
decided_at TIMESTAMPTZ,
decided_by UUID REFERENCES players(id) ON DELETE SET NULL
);
-- Only one Active row per (group, player).
-- Re-application after Rejected/Left creates a new row.
CREATE UNIQUE INDEX ux_club_memberships_one_active
ON club_memberships (group_id, player_id)
WHERE status = 'Active';
CREATE INDEX ix_club_memberships_group_status
ON club_memberships (group_id, status);
CREATE INDEX ix_club_memberships_player_status
ON club_memberships (player_id, status);
-- 2. sessions.publication_mode (replaces is_public)
ALTER TABLE sessions
ADD COLUMN publication_mode VARCHAR(20) NOT NULL DEFAULT 'None';
-- Backfill before constraint so existing data maps cleanly.
UPDATE sessions SET publication_mode = 'Both' WHERE is_public = true;
UPDATE sessions SET publication_mode = 'None' WHERE is_public = false;
ALTER TABLE sessions
ADD CONSTRAINT ck_sessions_publication_mode
CHECK (publication_mode IN ('None', 'Catalog', 'ClubOnly', 'Both'));
ALTER TABLE sessions DROP COLUMN is_public;
DROP INDEX IF EXISTS ix_sessions_public_schedule;
DROP INDEX IF EXISTS ix_sessions_showcase;
CREATE INDEX ix_sessions_public_schedule
ON sessions (group_id, scheduled_at)
WHERE publication_mode IN ('Catalog', 'Both') AND status <> 'Cancelled';
CREATE INDEX ix_sessions_showcase
ON sessions (scheduled_at, system, is_one_shot, format)
WHERE publication_mode IN ('Catalog', 'Both') AND status <> 'Cancelled';
-- 3. portfolio_games.publication_mode
-- Existing rows in portfolio_games keep 'Both' to stay visible to anonymous visitors.
ALTER TABLE portfolio_games
ADD COLUMN publication_mode VARCHAR(20) NOT NULL DEFAULT 'Both'
CHECK (publication_mode IN ('None', 'Catalog', 'ClubOnly', 'Both'));
CREATE INDEX ix_portfolio_games_showcase
ON portfolio_games (created_at DESC)
WHERE publication_mode IN ('Catalog', 'Both');
@@ -0,0 +1,21 @@
-- V031: Per-(chat, thread, owner) wizard drafts for the game-creation wizard (issue #111).
-- Stores in-progress wizard state in JSONB with a 24h TTL managed by WizardDraftCleanupService.
CREATE TABLE wizard_drafts (
id UUID PRIMARY KEY,
chat_id BIGINT NOT NULL,
message_thread_id INT,
owner_telegram_id BIGINT NOT NULL,
step TEXT NOT NULL,
payload JSONB NOT NULL,
draft_message_id BIGINT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_wizard_drafts_owner
ON wizard_drafts(chat_id, message_thread_id, owner_telegram_id);
CREATE INDEX idx_wizard_drafts_expires
ON wizard_drafts(expires_at);
@@ -0,0 +1,40 @@
-- V032: Platform-neutral wizard drafts (issue #112).
-- Adds the platform discriminator and switches owner/chat/thread/message
-- columns from numeric to TEXT so the same table can hold both Telegram
-- ids (long) and Discord snowflakes (ulong). All conversions are safe:
-- the affected columns are nullable except chat_id/owner_telegram_id
-- which we cast via TEXT.
ALTER TABLE wizard_drafts
ADD COLUMN platform TEXT NOT NULL DEFAULT 'Telegram';
-- Convert chat_id: BIGINT → TEXT. Existing rows hold Telegram chat ids
-- which convert losslessly to their decimal string form.
ALTER TABLE wizard_drafts
ALTER COLUMN chat_id TYPE TEXT USING chat_id::TEXT;
-- Convert message_thread_id: INT (nullable) → TEXT (nullable).
ALTER TABLE wizard_drafts
ALTER COLUMN message_thread_id TYPE TEXT USING message_thread_id::TEXT;
-- Convert draft_message_id: BIGINT (nullable) → TEXT (nullable).
ALTER TABLE wizard_drafts
ALTER COLUMN draft_message_id TYPE TEXT USING draft_message_id::TEXT;
-- Rename owner_telegram_id → owner_id (now platform-agnostic) and
-- convert from BIGINT to TEXT.
ALTER TABLE wizard_drafts
RENAME COLUMN owner_telegram_id TO owner_id;
ALTER TABLE wizard_drafts
ALTER COLUMN owner_id TYPE TEXT USING owner_id::TEXT;
-- Replace the old owner lookup index with one that uses the new column
-- names and the platform discriminator.
DROP INDEX IF EXISTS idx_wizard_drafts_owner;
CREATE INDEX idx_wizard_drafts_owner
ON wizard_drafts(platform, owner_id);
CREATE INDEX idx_wizard_drafts_platform
ON wizard_drafts(platform);
+60 -9
View File
@@ -1,11 +1,21 @@
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
using GmRelay.Bot.Features.Reminders.SendJoinLink;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Bot.Infrastructure.Database;
using GmRelay.Bot.Infrastructure.Scheduling;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Bot.Infrastructure.Health;
using GmRelay.Bot.Infrastructure.Logging;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Features.Confirmation.SendConfirmation;
using GmRelay.Shared.Features.Notifications;
using GmRelay.Shared.Features.Reminders.SendJoinLink;
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Infrastructure.Scheduling;
using GmRelay.Shared.Platform;
using Npgsql;
using Telegram.Bot;
@@ -20,11 +30,16 @@ builder.AddServiceDefaults();
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.");
Console.WriteLine($"[DBG] Master ConnectionString => {connectionString}");
var logger = loggerFactory.CreateLogger("GmRelay.Bot.Startup");
logger.LogInformation(
"Configured PostgreSQL data source with connection string {ConnectionString}",
SecretRedactor.RedactConnectionString(connectionString));
return NpgsqlDataSource.Create(connectionString);
});
@@ -40,27 +55,63 @@ builder.Services.AddSingleton<ITelegramBotClient>(sp =>
"Telegram:BotToken is required. Set via environment variable Telegram__BotToken or appsettings.json.");
return new TelegramBotClient(token);
});
builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>();
builder.Services.AddSingleton<IPlatformMessenger, TelegramPlatformMessenger>();
builder.Services.AddSingleton(new PlatformSchedulerOptions(PlatformKind.Telegram));
// ── Feature handlers (explicit registration — AOT safe) ──────────────
builder.Services.AddSingleton<SendConfirmationHandler>();
builder.Services.AddSingleton<ISendConfirmationHandler>(sp => sp.GetRequiredService<SendConfirmationHandler>());
builder.Services.AddSingleton<PlatformDirectNotificationSender>();
builder.Services.AddSingleton<HandleRsvpHandler>();
builder.Services.AddSingleton<SendJoinLinkHandler>();
builder.Services.AddSingleton<CreateSessionHandler>();
builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>());
builder.Services.AddSingleton<SendOneHourReminderHandler>();
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler>();
// Wizard services (issue #111)
builder.Services.AddSingleton<IWizardDraftRepository, WizardDraftRepository>();
builder.Services.AddSingleton<IWizardMessenger, TelegramWizardMessenger>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard>();
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
builder.Services.AddSingleton<JoinSessionHandler>();
builder.Services.AddSingleton<LeaveSessionHandler>();
builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
builder.Services.AddSingleton<CancelSessionHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.DeleteSessionHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.ListSessionsHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ExportCalendar.ExportCalendarHandler>();
builder.Services.AddSingleton<InitiateRescheduleHandler>();
builder.Services.AddSingleton<HandleRescheduleTimeInputHandler>();
builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
builder.Services.AddSingleton<DirectSessionNotificationSender>();
// ── Telegram infrastructure ──────────────────────────────────────────
builder.Services.AddSingleton<UpdateRouter>();
builder.Services.AddSingleton<ITelegramUpdateHandler>(sp => sp.GetRequiredService<UpdateRouter>());
builder.Services.AddHostedService<TelegramMiniAppMenuButtonService>();
builder.Services.AddHostedService<TelegramBotService>();
// ── Clock and scheduling ──────────────────────────────────────────────
builder.Services.AddSingleton<ISystemClock, GmRelay.Bot.Infrastructure.Scheduling.SystemClock>();
builder.Services.AddSingleton<ISessionTriggerStore, DbSessionTriggerStore>();
// ── Session scheduler ────────────────────────────────────────────────
builder.Services.AddHostedService<SessionSchedulerService>();
builder.Services.AddHostedService<RescheduleVotingDeadlineService>();
builder.Services.AddHostedService<WizardDraftCleanupService>();
// ── Health check server ──────────────────────────────────────────────
builder.Services.AddHostedService<BotHealthCheckHostedService>();
var host = builder.Build();
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("GmRelay.Bot.Tests")]
+5 -1
View File
@@ -7,6 +7,10 @@
}
},
"Telegram": {
"BotToken": ""
"BotToken": "",
"MiniAppUrl": ""
},
"Web": {
"BaseUrl": ""
}
}
+695
View File
@@ -0,0 +1,695 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Aspire.Npgsql": {
"type": "Direct",
"requested": "[13.2.2, )",
"resolved": "13.2.2",
"contentHash": "nEYgziWN7hksgEQEWy24JypcMCU8gKYcIIyPL05JfdXxUWuPRLotH/KOeuHevAjSEOYkL3dtGakBkJAuPobGmA==",
"dependencies": {
"AspNetCore.HealthChecks.NpgSql": "9.0.0",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics.HealthChecks": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5",
"Npgsql.DependencyInjection": "10.0.1",
"Npgsql.OpenTelemetry": "10.0.1",
"OpenTelemetry.Extensions.Hosting": "1.15.0"
}
},
"Dapper": {
"type": "Direct",
"requested": "[2.1.72, )",
"resolved": "2.1.72",
"contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw=="
},
"Dapper.AOT": {
"type": "Direct",
"requested": "[1.0.48, )",
"resolved": "1.0.48",
"contentHash": "rsLM3yKr4g+YKKox9lhc8D+kz67P7Q9+xdyn1LmCsoYr1kYpJSm+Nt6slo5UrfUrcTiGJ57zUlyO8XUdV7G7iA=="
},
"dbup-postgresql": {
"type": "Direct",
"requested": "[7.0.1, )",
"resolved": "7.0.1",
"contentHash": "mRnmENWWPuuMZ538gOd1mZnzucx6FQk0anmw3EABjGfcbp24FDb9QdGepYrDiaM8K9s5/gd49+5cmBOlniH/lg==",
"dependencies": {
"Npgsql": "10.0.1",
"dbup-core": "6.1.1"
}
},
"Microsoft.DotNet.ILCompiler": {
"type": "Direct",
"requested": "[10.0.5, )",
"resolved": "10.0.5",
"contentHash": "yadTZIkStCVsG8nGwvfroSfBApPsgjQbodQyaIfp53dgayE0qhZpywixiCB6lx57JYQ+KVg1m1AFLrj54pxpZg=="
},
"Microsoft.Extensions.Hosting": {
"type": "Direct",
"requested": "[10.0.5, )",
"resolved": "10.0.5",
"contentHash": "8i7e5IBdiKLNqt/+ciWrS8U95Rv5DClaaj7ulkZbimnCi4uREWd+lXzkp3joofFuIPOlAzV4AckxLTIELv2jdg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.Configuration.CommandLine": "10.0.5",
"Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.5",
"Microsoft.Extensions.Configuration.FileExtensions": "10.0.5",
"Microsoft.Extensions.Configuration.Json": "10.0.5",
"Microsoft.Extensions.Configuration.UserSecrets": "10.0.5",
"Microsoft.Extensions.DependencyInjection": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Physical": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Configuration": "10.0.5",
"Microsoft.Extensions.Logging.Console": "10.0.5",
"Microsoft.Extensions.Logging.Debug": "10.0.5",
"Microsoft.Extensions.Logging.EventLog": "10.0.5",
"Microsoft.Extensions.Logging.EventSource": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.5, )",
"resolved": "10.0.5",
"contentHash": "A+5ZuQ0f449tM+MQrhf6R9ZX7lYpjk/ODEwLYKrnF6111rtARx8fVsm4YznUnQiKnnXfaXNBqgxmil6RW3L3SA=="
},
"Npgsql": {
"type": "Direct",
"requested": "[10.0.2, )",
"resolved": "10.0.2",
"contentHash": "q5RfBI+wywJSFUNDE1L4ZbHEHCFTblo8Uf6A6oe4feOUFYiUQXyAf9GBh5qEZpvJaHiEbpBPkQumjEhXCJxdrg==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "10.0.0"
}
},
"SecurityCodeScan.VS2019": {
"type": "Direct",
"requested": "[5.6.7, )",
"resolved": "5.6.7",
"contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ=="
},
"Telegram.Bot": {
"type": "Direct",
"requested": "[22.9.5.3, )",
"resolved": "22.9.5.3",
"contentHash": "7u8rZU9Vx9XEyIm6pB+dAlITsi1v63I+hKo7IEXGiQZnVjzvZgPs9yDCP17/Cwm7lgjCNEqknlbv/yoBnsUYFw==",
"dependencies": {
"Microsoft.Extensions.Options.ConfigurationExtensions": "8.0.0"
}
},
"AspNetCore.HealthChecks.NpgSql": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "npc58/AD5zuVxERdhCl2Kb7WnL37mwX42SJcXIwvmEig0/dugOLg3SIwtfvvh3TnvTwR/sk5LYNkkPaBdks61A==",
"dependencies": {
"Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11",
"Npgsql": "8.0.3"
}
},
"dbup-core": {
"type": "Transitive",
"resolved": "6.1.1",
"contentHash": "kgpuyJVEFJHoIj/slnc994Go88aoeZqNDfGHDBr4sh7CsEWwJhOTCt/FJqO4ziUImL5L0NEY0kxxOiNgPKI2Fw==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "8.0.0"
}
},
"Microsoft.Extensions.AmbientMetadata.Application": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "CNrEjaOCZ8d1HtB0mvpiX4EWxLkee2xy+CsYXxmsEYJSFgw3OmF9pIhP/tCTeYBHhpsKJj5wM63G8IBFGxAcsw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.2",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.2",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.2"
}
},
"Microsoft.Extensions.Compliance.Abstractions": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "1a4xDAT6fRyP8t419q3WvWMmMslDTvI7OAZLWBhn5rysFG0bl5xFenTswd1xAbT/3u3mx4Xyb5bPx+V+18tJeQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2",
"Microsoft.Extensions.ObjectPool": "10.0.2"
}
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "8Rx5sqg04FttxrumyG6bmoRuFRgYzK6IVwF1i0/o0cXfKBdDeVpJejKHtJCMjyg9E/DNMVqpqOGe/tCT5gYvVA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "P09QpTHjqHmCLQOTC+WyLkoRNxek4NIvfWt+TnU0etoDUSRxcltyd6+j/ouRbMdLR0j44GqGO+lhI2M4fAHG4g==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "99Z4rjyXopb1MIazDSPcvwYCUdYNO01Cf1GUs2WUjIFAbkGmwzj2vPa2k+3pheJRV+YgNd2QqRKHAri0oBAU4Q==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.CommandLine": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "or9fOLopMUTJOQVJ3bou4aD6PwvsiKf4kZC4EE5sRRKSkmh+wfk/LekJXRjAX88X+1JA9zHjDo+5fiQ7z3MY/A==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.EnvironmentVariables": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "tchMGQ+zVTO40np/Zzg2Li/TIR8bksQgg4UVXZa0OzeFCKWnIYtxE2FVs+eSmjPGCjMS2voZbwN/mUcYfpSTuA==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.FileExtensions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "OhTr0O79dP49734lLTqVveivVX9sDXxbI/8vjELAZTHXqoN90mdpgTAgwicJED42iaHMCcZcK6Bj+8wNyBikaw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Physical": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.Json": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "brBM/WP0YAUYh2+QqSYVdK8eQHYQTtTEUJXJ+84Zkdo2buGLja9VSrMIhgoeBUU7JBmcskAib8Lb/N83bvxgYQ==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.FileExtensions": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.UserSecrets": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "fhdG6UV9lIp70QhNkVyaHciUVq25IPFkczheVJL9bIFvmnJ+Zghaie6dWkDbbVmxZlHl9gj3zTDxMxJs5zNhIA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Json": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Physical": "10.0.5"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "v1SVsowG6YE1YnHVGmLWz57YTRCQRx9pH5ebIESXfm5isI9gA3QaMyg/oMTzPpXYZwSAVDzYItGJKfmV+pqXkQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA=="
},
"Microsoft.Extensions.DependencyInjection.AutoActivation": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "Z/OI261l7LnxyODKPx0trQyIHFyicCR/akfn64lGOjPcf4FpAZ7ePAGl2HPvQBUBSNfPTF0gWeCfuFmyftMgYA==",
"dependencies": {
"Microsoft.Extensions.Hosting.Abstractions": "10.0.2"
}
},
"Microsoft.Extensions.Diagnostics": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "vAJHd4yOpmKoK+jBuYV7a3y+Ab9U4ARCc29b6qvMy276RgJFw9LFs0DdsPqOL3ahwzyrX7tM+i4cCxU/RX0qAg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.5"
}
},
"Microsoft.Extensions.Diagnostics.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "/nYGrpa9/0BZofrVpBbbj+Ns8ZesiPE0V/KxsuHgDgHQopIzN54nRaQGSuvPw16/kI9sW1Zox5yyAPqvf0Jz6A==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Diagnostics.ExceptionSummarization": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "3qMK1D40D10kb5TdBtFJpzz6/WH0NinWs68ZZS8jCFgHMXDiOjGiPOneMmIocCP/wnUUW4Hzf8lMsIE1xIGxDA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2"
}
},
"Microsoft.Extensions.Diagnostics.HealthChecks": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "REdt95QXHscGdtw/UUgyCW2lF9DJcAOJxmebKW2IkgUjuCAdMODIi2HNOWg5utW98nm8ekgV0Gjqs/sljwwqMw==",
"dependencies": {
"Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "NrIMTy7dpqxAvA6kHAYH8cXID/YgeNOy0OqFKpLtkPu5X4WS/basX91UszANzVrMNRAICJ2GOnGiRxJtsRyEQw=="
},
"Microsoft.Extensions.Features": {
"type": "Transitive",
"resolved": "10.0.2",
"contentHash": "X7tm2aV2w3lN9roSSGhl19lz4w76HvdiuKNhIv2XOiorYII9XCm66o/z9IJ0+QwkgvEv5gMZDM6rV6uwABHEQQ=="
},
"Microsoft.Extensions.FileProviders.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "nCBmCx0Xemlu65ZiWMcXbvfvtznKxf4/YYKF9R28QkqdI9lTikedGqzJ28/xmdGGsxUnsP5/3TQGpiPwVjK0dA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.FileProviders.Physical": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "dMu5kUPSfol1Rqhmr6nWPSmbFjDe9w6bkoKithG17bWTZA0UyKirTatM5mqYUN3mGpNA0MorlusIoVTh6J7o5g==",
"dependencies": {
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileSystemGlobbing": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.FileSystemGlobbing": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "mOE3ARusNQR0a5x8YOcnUbfyyXGqoAWQtEc7qFOfNJgruDWQLo39Re+3/Lzj5pLPFuFYj8hN4dgKzaSQDKiOCw=="
},
"Microsoft.Extensions.Hosting.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "+Wb7KAMVZTomwJkQrjuPTe5KBzGod7N8XeG+ScxRlkPOB4sZLG4ccVwjV4Phk5BCJt7uIMnGHVoN6ZMVploX+g==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Http": {
"type": "Transitive",
"resolved": "10.0.2",
"contentHash": "egUPC0xydb1ugCMcRyJ6zaOGOzx7N4coOVlGeLcIsXhUf1xHHwZeX+ob7JuG0dXExFduHYE/t+4/4y8BLlBKmw==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.2",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2",
"Microsoft.Extensions.Diagnostics": "10.0.2",
"Microsoft.Extensions.Logging": "10.0.2",
"Microsoft.Extensions.Logging.Abstractions": "10.0.2",
"Microsoft.Extensions.Options": "10.0.2"
}
},
"Microsoft.Extensions.Http.Diagnostics": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "I0FBgF6yZRwYH9E3KQ2vHm80YZ7YBj+52GDsmOWXPBv/p15b/wUoNupV9kw3LnSNVsWMqlGbiuZgBnHpMwPh+Q==",
"dependencies": {
"Microsoft.Extensions.Http": "10.0.2",
"Microsoft.Extensions.Telemetry": "10.2.0"
}
},
"Microsoft.Extensions.Http.Resilience": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "Lg+OjBW+ODDbM4Ax4LoERvQ1dqSZ8I2gQc2+B0/WOWl2+PunLJ3xb3x8MtHGfcb/Mp98RoMpwRKm6Aj9mzXwrA==",
"dependencies": {
"Microsoft.Extensions.Http.Diagnostics": "10.2.0",
"Microsoft.Extensions.ObjectPool": "10.0.2",
"Microsoft.Extensions.Resilience": "10.2.0"
}
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "+XTMKQyDWg4ODoNHU/BN3BaI1jhGO7VCS+BnzT/4IauiG6y2iPAte7MyD7rHKS+hNP0TkFkjrae8DFjDUxtcxg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "9HOdqlDtPptVcmKAjsQ/Nr5Rxfq6FMYLdhvZh1lVmeKR738qeYecQD7+ldooXf+u2KzzR1kafSphWngIM3C6ug==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Configuration": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "cSgxsDgfP0+gmVRPVoNHI/KIDavIZxh+CxE6tSLPlYTogqccDnjBFI9CgEsiNuMP6+fiuXUwhhlTz36uUEpwbQ==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Console": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "PMs2gha2v24hvH5o5KQem5aNK4mN0BhhCWlMqsg9tzifWKzjeQi2tyPOP/RaWMVvalOhVLcrmoMYPqbnia/epg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Configuration": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Debug": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "/VacEkBQ02A8PBXSa6YpbIXCuisYy6JJr62/+ANJDZE+RMBfZMcXJXLfr/LpyLE6pgdp17Wxlt7e7R9zvkwZ3Q==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Logging.EventLog": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "0ezhWYJS4/6KrqQel9JL+Tr4n+4EX2TF5EYiaysBWNNEM2c3Gtj1moD39esfgk8OHblSX+UFjtZ3z0c4i9tRvw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"System.Diagnostics.EventLog": "10.0.5"
}
},
"Microsoft.Extensions.Logging.EventSource": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "vN+aq1hBFXyYvY5Ow9WyeR66drKQxRZmas4lAjh6QWfryPkjTn1uLtX5AFIxyDaZj78v5TG2sELUyvrXpAPQQw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.ObjectPool": {
"type": "Transitive",
"resolved": "10.0.2",
"contentHash": "kpCp4m7nwJVBcRKWXYHdVK/W0dkKyyFOjCmKVdO+zKThWvUxP1V+jVEP9FGpqRu4GPl9041SEXu2f+U/l825nQ=="
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "MDaQMdUplw0AIRhWWmbLA7yQEXaLIHb+9CTroTiNS8OlI0LMXS4LCxtopqauiqGCWlRgJ+xyraVD8t6veRAFbw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Options.ConfigurationExtensions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "BB9uUW3+6Rxu1R97OB1H/13lUF8P2+H1+eDhpZlK30kDh/6E4EKHBUqTp+ilXQmZLzsRErxON8aBSR6WpUKJdg==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "/HUHJ0tw/LQvD0DZrz50eQy/3z7PfX7WWEaXnjKTV9/TNdcgFlNTZGo49QhS7PTmhDqMyHRMqAXSBxLh0vso4g=="
},
"Microsoft.Extensions.Resilience": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "v4WOdAOFxB3AcsUkZWNcHL3mYzs4KAPtHO8rkoQlFKOBoD3KyjjAL+h3tRwSK5i4UpF/yhxsQRY0JxKj4osxxw==",
"dependencies": {
"Microsoft.Extensions.Diagnostics": "10.0.2",
"Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.2.0",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.2",
"Microsoft.Extensions.Telemetry.Abstractions": "10.2.0",
"Polly.Extensions": "8.4.2",
"Polly.RateLimiting": "8.4.2"
}
},
"Microsoft.Extensions.ServiceDiscovery": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "AHTPfiKodj66xA8RwRkFD4q11V2AvzcuDsujv6ViPkOPtvBEYcPVplHakK56pPzWlX08MDS+TAQXfFXAeP7J5w==",
"dependencies": {
"Microsoft.Extensions.Http": "10.0.2",
"Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.2.0"
}
},
"Microsoft.Extensions.ServiceDiscovery.Abstractions": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "sANlOvfqfw/yfych4CLlHSKSWzIie6mQG7w83gVur1foNOafyHxcgpoQMvBf+KiB4Tpls6P1/Z77IIQSK8hxFg==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.2",
"Microsoft.Extensions.Configuration.Binder": "10.0.2",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2",
"Microsoft.Extensions.Features": "10.0.2",
"Microsoft.Extensions.Logging.Abstractions": "10.0.2",
"Microsoft.Extensions.Options": "10.0.2",
"Microsoft.Extensions.Primitives": "10.0.2"
}
},
"Microsoft.Extensions.Telemetry": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "ssW5gosYlewNH/ISTyaLD/XfJT4GSjwShOUKv61fpXrqVmHkhuIA/5bBAGStM1XbzJjt9IG2vzfdHTu4zlX9Ew==",
"dependencies": {
"Microsoft.Extensions.AmbientMetadata.Application": "10.2.0",
"Microsoft.Extensions.DependencyInjection.AutoActivation": "10.2.0",
"Microsoft.Extensions.Logging.Configuration": "10.0.2",
"Microsoft.Extensions.ObjectPool": "10.0.2",
"Microsoft.Extensions.Telemetry.Abstractions": "10.2.0"
}
},
"Microsoft.Extensions.Telemetry.Abstractions": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "6V4V6NX6RLUYWwV89DeW/4zK5xOycYHWhsfMXSpKVGgMHfXcczmbk6hBeqTnRPzhpATYcOWlmA6hk1jgdxUugA==",
"dependencies": {
"Microsoft.Extensions.Compliance.Abstractions": "10.2.0",
"Microsoft.Extensions.Logging.Abstractions": "10.0.2",
"Microsoft.Extensions.ObjectPool": "10.0.2",
"Microsoft.Extensions.Options": "10.0.2"
}
},
"Npgsql.DependencyInjection": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "YHFa4vD27sNIfv6s5q8Zi1fLvKfmK1xcpMv0PUvXOxDFbRmuMRSHwpZTbPvsAlj97q1/o7DfyynLqfqrCm1VnA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Npgsql": "10.0.1"
}
},
"Npgsql.OpenTelemetry": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "G9fEIBaHggZXWfDSDnKLc0XwKcbuU6i2eXp7zDqpgYxbhCmIN9fRgaSOGyyMNHSo/yY1IB4G4CjW5VO/SKRR0g==",
"dependencies": {
"Npgsql": "10.0.1",
"OpenTelemetry.API": "1.14.0"
}
},
"OpenTelemetry": {
"type": "Transitive",
"resolved": "1.15.3",
"contentHash": "N0i6WjPoHPbZyms1ugbDIFAJFuGlpeExJMU/+XSL0lQRUkg/D0utFkDoLXf8Z1km5B+xVZ2GyMXXiX8qdeNmPg==",
"dependencies": {
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0",
"Microsoft.Extensions.Logging.Configuration": "10.0.0",
"OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.3"
}
},
"OpenTelemetry.Api": {
"type": "Transitive",
"resolved": "1.15.3",
"contentHash": "fX+fkCysfPut+qCcT3bKqyX4QN9Saf4CgX8HLOHywEVD+Xr7sULtfuypITpoDysjx8R59dn/3mWhgimMH8cm/g=="
},
"OpenTelemetry.Api.ProviderBuilderExtensions": {
"type": "Transitive",
"resolved": "1.15.3",
"contentHash": "SYn0lqYDwLMWhv/zlNGsQcl2yX++yTumanX46bmOZE/ZDOd1WjPBO2kZaZgKLEZTZk48pavIFGJ6vOvxXgWVFQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0",
"OpenTelemetry.Api": "1.15.3"
}
},
"OpenTelemetry.Exporter.OpenTelemetryProtocol": {
"type": "Transitive",
"resolved": "1.15.3",
"contentHash": "FEXJepcseTGbATiCkUfP7ipoFEYYfl/0UmmUwi0KxCPg9PaUA8ab2P1LGopK+/HExasJ1ZutFhZrN6WvUIR23g==",
"dependencies": {
"OpenTelemetry": "1.15.3"
}
},
"OpenTelemetry.Extensions.Hosting": {
"type": "Transitive",
"resolved": "1.15.3",
"contentHash": "u8n/W8yIlqv0BXZmvId1iVaeWXG42tGKdTkuLYg5g57Y/r9CeUNzqtrSHNdG5IoO8iPX79w3v+WsbAHgUQbfeg==",
"dependencies": {
"Microsoft.Extensions.Hosting.Abstractions": "10.0.0",
"OpenTelemetry": "1.15.3"
}
},
"OpenTelemetry.Instrumentation.AspNetCore": {
"type": "Transitive",
"resolved": "1.15.2",
"contentHash": "2nPd7r0ug/gd6/CNFL6Rlu+RSQ9WYGSGHAYQ1ssbSqyzKJpqTunfx2I/1O0WB5k+L0cyXbG4XVZpoSoUc3M7wg==",
"dependencies": {
"OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)"
}
},
"OpenTelemetry.Instrumentation.Http": {
"type": "Transitive",
"resolved": "1.15.1",
"contentHash": "vFO4Fj/dXkoVNGo/nhoGpO2zYQmZwr4jTID7oRGo+XlQ8LqksyZjUXQ4p39RfUvTID7IzzL8Qe71tW7CcAFymA==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.0",
"Microsoft.Extensions.Options": "10.0.0",
"OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)"
}
},
"OpenTelemetry.Instrumentation.Runtime": {
"type": "Transitive",
"resolved": "1.15.1",
"contentHash": "cpPwlUT5HXcLGPaIgsbSy0W9eFYAPGVbTP1p8/uyQ4Osvf5BJuPpEXE7crL09SmEd44r0DGNKDtsqxaAz0HxQw==",
"dependencies": {
"OpenTelemetry.Api": "[1.15.3, 2.0.0)"
}
},
"Polly.Core": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g=="
},
"Polly.Extensions": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "8.0.0",
"Microsoft.Extensions.Options": "8.0.0",
"Polly.Core": "8.4.2"
}
},
"Polly.RateLimiting": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==",
"dependencies": {
"Polly.Core": "8.4.2",
"System.Threading.RateLimiting": "8.0.0"
}
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "wugvy+pBVzjQEnRs9wMTWwoaeNFX3hsaHeVHFDIvJSWXp7wfmNWu3mxAwBIE6pyW+g6+rHa1Of5fTzb0QVqUTA=="
},
"System.Threading.RateLimiting": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q=="
},
"gmrelay.servicedefaults": {
"type": "Project",
"dependencies": {
"Microsoft.Extensions.Http.Resilience": "[10.2.0, )",
"Microsoft.Extensions.ServiceDiscovery": "[10.2.0, )",
"OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )",
"OpenTelemetry.Extensions.Hosting": "[1.15.3, )",
"OpenTelemetry.Instrumentation.AspNetCore": "[1.15.2, )",
"OpenTelemetry.Instrumentation.Http": "[1.15.1, )",
"OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )"
}
},
"gmrelay.shared": {
"type": "Project",
"dependencies": {
"Dapper": "[2.1.72, )",
"Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )",
"Microsoft.Extensions.Logging.Abstractions": "[10.0.5, )",
"Npgsql": "[10.0.2, )"
}
}
},
"net10.0/win-x64": {
"Microsoft.DotNet.ILCompiler": {
"type": "Direct",
"requested": "[10.0.5, )",
"resolved": "10.0.5",
"contentHash": "yadTZIkStCVsG8nGwvfroSfBApPsgjQbodQyaIfp53dgayE0qhZpywixiCB6lx57JYQ+KVg1m1AFLrj54pxpZg==",
"dependencies": {
"runtime.win-x64.Microsoft.DotNet.ILCompiler": "10.0.5"
}
},
"runtime.win-x64.Microsoft.DotNet.ILCompiler": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "vblLkpVhSDYOmrEW0jypX7YVtLg7idU1QzUyx45ZdZ2sFUSSf3mYFCr0FW3+KZgXWpN1ve9ZPrxNywvHISF4bA=="
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "wugvy+pBVzjQEnRs9wMTWwoaeNFX3hsaHeVHFDIvJSWXp7wfmNWu3mxAwBIE6pyW+g6+rHa1Of5fTzb0QVqUTA=="
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
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.");
}
}
}
+25
View File
@@ -0,0 +1,25 @@
# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS build
WORKDIR /src
COPY ["src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj", "src/GmRelay.DiscordBot/"]
COPY ["src/GmRelay.ServiceDefaults/GmRelay.ServiceDefaults.csproj", "src/GmRelay.ServiceDefaults/"]
COPY ["src/GmRelay.Shared/GmRelay.Shared.csproj", "src/GmRelay.Shared/"]
RUN dotnet restore "src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj"
COPY src/ src/
WORKDIR /src/src/GmRelay.DiscordBot
RUN dotnet publish "GmRelay.DiscordBot.csproj" -c Release -o /app/publish /p:UseAppHost=false
# Stage 2: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble AS final
WORKDIR /app
# Install wget for healthcheck
RUN apt-get update && apt-get install -y --no-install-recommends wget \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/publish .
USER $APP_UID
ENTRYPOINT ["dotnet", "GmRelay.DiscordBot.dll"]
@@ -0,0 +1,119 @@
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Rendering;
using Npgsql;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record DiscordDeleteSessionResult(
string ReplyText,
SessionBatchViewModel? UpdatedView,
string? EmptyMessage = null);
public sealed class DiscordDeleteSessionHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker,
DiscordListSessionsHandler listSessionsHandler,
ILogger<DiscordDeleteSessionHandler> logger)
{
public async Task<DiscordDeleteSessionResult> HandleAsync(
string guildId,
string channelId,
ulong userId,
ulong resolvedPermissions,
ulong guildOwnerId,
Guid sessionId,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
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 p.platform = 'Discord'
AND g.external_group_id = @GuildId",
new { GuildId = guildId });
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
{
return new DiscordDeleteSessionResult(
"Только owner, администратор или manager могут удалять сессии.",
UpdatedView: null);
}
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
await connection.ExecuteAsync(
"SELECT pg_advisory_xact_lock(20260530, 108)",
transaction: transaction);
_ = await connection.QuerySingleOrDefaultAsync<Guid?>(
"""
SELECT s.id
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId
AND g.platform = 'Discord'
AND g.external_group_id = @GuildId
FOR UPDATE OF s
""",
new { SessionId = sessionId, GuildId = guildId },
transaction);
await connection.ExecuteAsync(
"""
UPDATE portfolio_games pg
SET is_public = false,
updated_at = now()
FROM portfolio_game_sessions pgs
JOIN sessions s ON s.id = pgs.session_id
JOIN game_groups g ON g.id = s.group_id
WHERE pgs.portfolio_game_id = pg.id
AND s.id = @SessionId
AND g.platform = 'Discord'
AND g.external_group_id = @GuildId
AND pg.is_public = true
""",
new { SessionId = sessionId, GuildId = guildId },
transaction);
var deletedRows = await connection.ExecuteAsync(
"""
DELETE FROM sessions s
USING game_groups g
WHERE s.group_id = g.id
AND s.id = @SessionId
AND g.platform = 'Discord'
AND g.external_group_id = @GuildId
""",
new { SessionId = sessionId, GuildId = guildId },
transaction);
await transaction.CommitAsync(cancellationToken);
if (deletedRows == 0)
{
return new DiscordDeleteSessionResult(
"Сессия не найдена или уже удалена.",
UpdatedView: null);
}
logger.LogInformation("Deleted Discord session {SessionId} in guild {GuildId}", sessionId, guildId);
var updatedView = await listSessionsHandler.BuildScheduleAsync(
guildId,
channelId,
userId,
resolvedPermissions,
guildOwnerId,
cancellationToken);
return updatedView is null
? new DiscordDeleteSessionResult(
"Сессия удалена.",
UpdatedView: null,
EmptyMessage: "В этом сервере нет предстоящих игр.")
: new DiscordDeleteSessionResult("Сессия удалена.", updatedView);
}
}
@@ -0,0 +1,48 @@
using NetCord;
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
namespace GmRelay.DiscordBot.Features.Sessions;
public class DiscordListSessionsCommand : ApplicationCommandModule<SlashCommandContext>
{
private readonly DiscordListSessionsHandler _handler;
public DiscordListSessionsCommand(DiscordListSessionsHandler handler)
{
_handler = handler;
}
[SlashCommand("listsessions", "Show upcoming game sessions in this server")]
public async Task ExecuteAsync()
{
var guildId = Context.Interaction.GuildId?.ToString()
?? throw new InvalidOperationException("This command can only be used in a guild.");
var channelId = Context.Channel.Id.ToString();
var member = Context.User as GuildInteractionUser;
var resolvedPermissions = member is null ? 0UL : (ulong)member.Permissions;
var guildOwnerId = 0UL;
var view = await _handler.BuildScheduleAsync(
guildId,
channelId,
Context.User.Id,
resolvedPermissions,
guildOwnerId,
CancellationToken.None);
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)));
}
}
@@ -0,0 +1,110 @@
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
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,
DiscordPermissionChecker permissionChecker)
{
public Task<SessionBatchViewModel?> BuildScheduleAsync(
string guildId,
string channelId,
CancellationToken cancellationToken) =>
BuildScheduleAsync(guildId, channelId, 0, 0, 0, cancellationToken);
public async Task<SessionBatchViewModel?> BuildScheduleAsync(
string guildId,
string channelId,
ulong userId,
ulong resolvedPermissions,
ulong guildOwnerId,
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)::int as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted)::int 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() - interval '4 hours'
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 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 });
var canManage = permissionChecker.CanManageSchedule(
guildOwnerId,
userId,
dbManagerUserIds,
resolvedPermissions);
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,
p.external_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();
var view = SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
return canManage ? AddManagerActions(view) : view;
}
internal static SessionBatchViewModel AddManagerActions(SessionBatchViewModel view) =>
view with
{
Sessions = view.Sessions
.Select(session =>
{
if (SessionStatus.IsCancelled(session.Status))
return session;
var actions = session.AvailableActions
.Concat([new AvailableAction("delete_session", $"Удалить {session.ScheduledAt.FormatMoscowShort()}", session.SessionId)])
.ToList();
return session with { AvailableActions = actions };
})
.ToList()
};
}

Some files were not shown because too many files have changed in this diff Show More