Skip to content

feat(discord): link accounts to Discord users (M4) - #1160

Open
sven-n wants to merge 7 commits into
claude/sweet-cray-w8crms-m3from
claude/sweet-cray-w8crms-m4
Open

sven-n wants to merge 7 commits into
claude/sweet-cray-w8crms-m3from
claude/sweet-cray-w8crms-m4

Conversation

@sven-n

@sven-n sven-n commented Oct 9, 2026

Copy link
Copy Markdown
Member

Milestone M4 of the Discord integration (#1102). Stacked on #1157 (M3).

What

  • Data model (M4.1): new aggregate AccountExternalLink, refers to its account by id like the cash shop grants. Fields: provider, external user id and name, character name, linked at, plus the hash and expiry of a pending one-time code. Unique (AccountId, Provider) and (Provider, ExternalUserId). Includes the EF migration AddAccountExternalLink, regenerated persistence model classes and the compiled models of EntityDataContext, AccountContext and TradeContext. The links are in IPlayerContext (EF and in-memory).
  • AccountLinkService (GameLogic):
    • Codes: a code is 8 characters from an unambiguous alphabet, shown as ABCD-EFGH. It is valid for 10 minutes, stored as SHA-256, usable only once, and a new code invalidates the previous one. Entering it is case- and separator-insensitive.
    • Relinking: linking replaces the user's previous account link and the account's previous user.
    • Persistence: each call uses its own short-lived persistence context, so it doesn't interfere with a game server holding the account.
  • Linking flow (M4.2):
    • In game, the new chat command plugin /discord has three forms: /discord link shows a code, /discord unlink removes the link and publishes a new AccountUnlinkedEvent game event, and /discord shows the link.
    • In Discord, /link <code> and /unlink; answers are ephemeral.
    • Discord user ids are global, so a link counts on every Discord the bot is in.
  • Sender character (M4.3): the user appears as the character that requested the code. /character <name> selects another character of the same account (validated).
  • Linked Player role (M4.4):
    • Linking: the bot gives the layout role linked (Linked Player) to the user in the routing guild.
    • Unlinking: /unlink, replacing a link, or AccountUnlinkedEvent from the game remove the role again. The bot is now also an IGameEventListener.
    • Member lookup: members are fetched via REST, so no privileged intent is needed.
  • Admin panel (M4.1): a Links button in the account list opens /accounts/{login}/links, which shows the links and removes them (operator policy).
  • Docs: linking section and new commands in the Discord page; the bot needs Manage Roles for the role.

Notes

  • Removing a link in the admin panel doesn't remove the Discord role: the admin panel has no event publisher. This is documented.
  • Resources were added in English and German; Chinese falls back to English.

Tests

  • AccountLinkServiceTest: linking, one-time use, expiry and replaced codes, relinking, unlinking from both sides, character selection.
  • DiscordAccountCommandsTest: answers and role changes.
  • DiscordChatCommandPlugInTest: the in-game command.
  • Full solution test run: all green, including the persistence initialization and web tests.

Not tested: the migration wasn't applied to a real PostgreSQL database here, and nothing was run against a real Discord bot.

🤖 Generated with Claude Code

https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ


Generated by Claude Code

claude added 5 commits October 9, 2026 07:34
An AccountExternalLink links an account to a user of an external service
like Discord, with a pending one-time code for the linking. Like the cash
shop grants, it's a separate aggregate which refers to its account by id,
so the external service can change it while a game server holds the account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ
The chat command creates a one-time code, which the player enters in Discord.
/discord unlink removes the link and publishes an AccountUnlinkedEvent, so that
the Discord bot can remove the role of the user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ
…er role

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Deploying openmudocs with  Cloudflare Pages  Cloudflare Pages

Latest commit: d486b12
Status: ✅  Deploy successful!
Preview URL: https://32f847ce.openmudocs.pages.dev
Branch Preview URL: https://claude-sweet-cray-w8crms-m4.openmudocs.pages.dev

View logs

@sven-n sven-n left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Part of a review pass over the whole Discord stack. This is the security-sensitive one, so I went through the link flow in detail; it stands up well.

What I checked and found correct:

  • Code strength. 8 characters from a 32-symbol alphabet is ~2^40, valid for 10 minutes, generated with RandomNumberGenerator, stored only as a SHA-256 hash, and cleared on use. Confusable characters are excluded from the alphabet, and NormalizeCode means case and the separator don't matter on entry. Brute force isn't a practical concern at that size.
  • Uniqueness. The unique indexes on (AccountId, Provider) and (Provider, ExternalUserId) are both there, so the "user already linked elsewhere" race in LinkAsync can't produce duplicate rows — the second writer fails at the database instead. The second index relies on PostgreSQL treating NULLs as distinct so that multiple pending links coexist, which is what you want and is what it does.
  • Character ownership. SelectCharacterAsync verifies GetAccountIdByCharacterNameAsync(characterName) == link.AccountId, so /character can only select characters of the linked account. This matters a lot later, because M6 keys game-master authority off the selected character.
  • Secrecy of the code. Both DeferAsync and FollowupAsync pass ephemeral: true for the account commands, and the in-game side uses a blue message to the requesting player only.

Two comments inline. The admin-panel one is a real gap; the other is cosmetic.

One thing I'd consider as follow-up rather than a change here: nothing prunes CodeHash/CodeExpiresAt once a code expires unused, so stale hashes accumulate on rows that were never confirmed. Harmless (they can't be used, and the entropy makes them uninteresting even to someone reading the database), but a periodic cleanup would keep the table honest.


Generated by Claude Code

Comment thread src/Web/AdminPanel/Pages/AccountLinks.razor.cs Outdated
Comment thread src/GameLogic/PlugIns/ChatCommands/DiscordChatCommandPlugIn.cs Outdated
…rms-m4

The migration AddAccountExternalLink is generated again, so that it follows
the migrations of master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ
AccountLinkService.UnlinkAccountAsync publishes the AccountUnlinkedEvent,
so the admin panel and the chat command both make the bot remove the roles.
The all-in-one deployment registers its event publisher for it. /discord
unlink tells when there was no link, and a failed account command gets an
answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants