Skip to content

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

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

sven-n wants to merge 5 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

Copy link
Copy Markdown

Deploying openmudocs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 9cffa9a
Status: ✅  Deploy successful!
Preview URL: https://69112f90.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

this._isRemoving = true;
try
{
await new AccountLinkService(this.CreateContext).UnlinkAccountAsync(this._accountId, provider).ConfigureAwait(true);

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.

This removes the row but doesn't publish AccountUnlinkedEvent, so it's the only one of the three unlink paths that leaves the user's Discord role in place.

The other two both clean up:

  • in-game, DiscordChatCommandPlugIn's unlink branch publishes AccountUnlinkedEvent, which DiscordBot.OnGameEventAsync turns into a role removal;
  • from Discord, DiscordAccountCommands.UnlinkAsync returns a DiscordRoleChange that ExecuteAccountCommandAsync applies.

So an admin who unlinks an account here — which is presumably the path used when a link is being removed against the user's wishes — leaves them holding the "Linked" role. From M5 on that also means keeping access to hosted guild channels until the next SyncHostedChannels tick happens to correct it.

UnlinkAccountAsync already returns the external user id for exactly this purpose, so it's a matter of publishing the event with it. The admin panel doesn't have an IEventPublisher to hand the way a game server context does, which is probably why this was skipped; if wiring one in is awkward, the alternative is to move the event publication into AccountLinkService.UnlinkAccountAsync so every caller gets it by construction.

While here: there's no confirmation prompt on the remove button, which is a little out of step with #978 adding confirmed deletion to the account list. Much lower stakes than deleting an account, since the user can just link again, so I'd call that optional.


Generated by Claude Code

new AccountUnlinkedEvent(gameServerContext.Id, DateTime.UtcNow, AccountLinkService.DiscordProvider, userId)).ConfigureAwait(false);
}

await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.DiscordUnlinked)).ConfigureAwait(false);

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.

This message is shown unconditionally, outside the if, so /discord unlink reports success even when there was nothing linked — UnlinkAccountAsync returned null and nothing happened.

Minor, but the player has no way to tell "I removed your link" from "there was no link", and the state-showing branch below does distinguish the two cases. Moving it into the if and adding an else that reuses PlayerMessage.DiscordNotLinked would make the three branches consistent.


Generated by Claude Code

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