Skip to content

feat(discord): chat bridge for guild, alliance and world chat (M5) - #1161

Open
sven-n wants to merge 10 commits into
claude/sweet-cray-w8crms-m4from
claude/sweet-cray-w8crms-m5
Open

sven-n wants to merge 10 commits into
claude/sweet-cray-w8crms-m4from
claude/sweet-cray-w8crms-m5

Conversation

@sven-n

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

Copy link
Copy Markdown
Member

Milestone M5 of the Discord integration (#1103). Stacked on #1160 (M4).

What

  • World chat (M5.5):
    • In game: the new chat command /world <message> reaches all players of all game servers. The chat ban and IChatMessageReceivedPlugIn apply.
    • Plumbing: new IEventPublisher.WorldChatMessageAsync and IGameServer.WorldChatMessageAsync, implemented in-memory, as a Dapr topic and in GameServerClient.
    • Display: players see a localized blue message [World] Name: text.
    • Discord: the chat is bridged to the layout channel with isWorldChat (#world-chat).
  • Publishing chat (M5.4, game → Discord):
    • GameEventPublisherPlugIn publishes a new ChatMessageEvent for guild, alliance and world chat.
    • Gated by the new configuration flag Publish guild, alliance and world chat, off by default for privacy.
    • Discord → game uses IEventPublisher.GuildMessageAsync / AllianceMessageAsync / WorldChatMessageAsync.
    • The Dapr EventPublisher moved from GameServer.Host to Dapr.Common, so the Discord host can use it. In the all-in-one deployment, GameServerContainer.EventPublisher is registered as IEventPublisher and resolved lazily, because the publisher depends on the bot.
  • Binding model (M5.1): new aggregate GuildChatBinding with persistent guild id, scope (guild/alliance), provider, Discord server and channel id, hosted flag, bound by and bound at. It has a migration and compiled models, and an FK to Guild with cascade, so disbanding a guild removes its bindings (M5.8). Operator settings Bot__ChatBridge__*: BindingMode (Both/HostedOnly/GuildOwnedOnly), AllowedDiscordServerIds, ReadMessages, MaximumMessageLength, MaximumMessagesPerMinute.
  • Hosted mode (M5.2): /guildchat create [scope] creates #guild-<name> / #alliance-<name> in the new Guilds category of the layout. Only the bot and linked guild members can see it. Visibility uses per-user channel overwrites, synchronized after linking and every 10 status ticks.
  • Guild-owned mode (M5.3): the guild master invites the bot and runs /guildchat bind [scope] in the channel. That requires Manage Channel on that channel; the allowlist applies. Players get a localized in-game notice at login when their guild or alliance chat is mirrored (new GuildChatBridgeNoticePlugIn).
  • Who may post (M5.6):
    • Linking and membership: only linked users (M4) whose selected character still belongs to the account and is a member of the guild or alliance.
    • Limits: Account.ChatBanUntil applies, plus a flood limit (10 messages per minute by default).
    • Sanitizing: mentions are removed, <:name:id> becomes :name:, markdown and line breaks are stripped, and the length is capped. The sender is @ plus the character name, at most 10 characters, so Discord users can't impersonate characters.
  • Sending without privileged intent (M5.7):
    • Without the intent: /say <message> works in any bridged channel.
    • With the intent: with ReadMessages the bot uses the Message Content intent and reads normal messages, and reacts with ❌ when one is rejected.
    • Batching: game → Discord messages are batched per channel up to 2000 characters.
  • Lifecycle (M5.8): bindings are removed when the channel is deleted, the bot leaves the Discord server, /guildchat unbind is used (by the guild master or by users who manage the channel), or the guild is disbanded. When the guild master changes, the binding stays, but only the current master can change it.
  • Persistence helpers: IGuildServerContext.GetGuildMembershipAsync(characterName) and IPlayerContext.GetAccountChatBanUntilAsync(accountId), for EF and in-memory.

Deviations from the issue, for discussion

  • No in-game code for guild-owned binding. The M4 account link already proves control of the guild master character, and Manage Channel proves control of the Discord channel. So /discord bindguild + /bind <code> became /guildchat bind.
  • /say instead of /g and /w. It works in any bridged channel.
  • No Discord roles for hosted guild channels and no GM role sync (M5.9). Without the privileged Server Members intent the bot can't see who has a role, so it uses per-user channel overwrites, which it can read and reconcile. Role sync in guild-owned Discords is not implemented (optional in the issue).
  • No auto-creation of hosted channels at guild creation. The guild master creates the channel with /guildchat create.
  • No dedicated chat packet for MuMain yet. World chat is shown as a localized blue message; the dedicated packet is MuMain C4.

Tests

  • DiscordChatBridgeTest, against the in-memory DB with guilds, an alliance and linked users. It covers:
    • binding by guild or alliance master only, channel in use, mode and allowlist;
    • mirroring game → Discord;
    • posting: sent, not bridged, not linked, not a member, empty, flood, chat ban, world chat;
    • unbinding, and the linked members of guilds and alliances.
  • DiscordChatTextTest (sanitizing), DiscordChatQueueTest (batching).
  • GameEventPublisherPlugInTest: chat publishing on and off, the /world command, and the chat ban for it.
  • GuildMembershipQueryTest: EF query translation.
  • Layout and routing tests.
  • Full solution test run is green.
  • Smoke start of the all-in-one with the bot enabled (invalid token): no DI cycle, and the game servers start.

Not tested: against a real Discord bot or server, and the migration against a real PostgreSQL database.

🤖 Generated with Claude Code

https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ


Generated by Claude Code

claude added 4 commits October 9, 2026 08:17
/world sends a message to all players of all game servers. The game event
publisher plugin publishes the guild, alliance and world chat as
ChatMessageEvent, if it's enabled in its configuration (off by default).
Players are told when they enter the game, if the chat of their guild or
alliance is mirrored to Discord. The Dapr event publisher moves to the
common project, so that the Discord host can send chat messages, too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ
Guild masters bind the chat of their guild or alliance to a channel: on the
Discord server of the game server with /guildchat create, or on their own
Discord server with /guildchat bind. Linked users write into the game with
/say, or with normal messages when the Message Content intent is enabled.

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: 946d179
Status: ✅  Deploy successful!
Preview URL: https://d159a393.openmudocs.pages.dev
Branch Preview URL: https://claude-sweet-cray-w8crms-m5.openmudocs.pages.dev

View logs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ
sven-n pushed a commit to sven-n/MuMain that referenced this pull request Oct 10, 2026
OpenMU's chat bridge (MUnique/OpenMU#1161) sends messages written in Discord
through the ordinary guild and alliance chat, as the linked character with
an '@' prefix that no character name can contain. Such lines are now shown
through UI::Chat::External as messages from Discord: "[Discord] <name>" in
the guild or alliance colours, in the Discord view, and not whisperable.

This makes the C4 display path work against OpenMU today, without waiting
for a dedicated packet.

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

@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 biggest PR of the set and the one with the most to go wrong, so there are more comments here than elsewhere — most are about load rather than correctness.

What I checked specifically and found sound:

  • No feedback loop. Discord → game goes through IEventPublisher, which has no Player, so it can't re-trigger IChatMessageSentPlugIn and bounce back to the channel. I went looking for this because the echo in SayAsync plus the mirror in OnChatMessageAsync is exactly the shape that loops, and it doesn't.
  • Authorisation on binding. GetBindableTargetAsync requires the selected character of the linked account to be the guild master, and the alliance case additionally requires the guild to be the alliance master. Combined with M4's ownership check in SelectCharacterAsync, a user can't bind a guild they don't lead.
  • Posting permission. Linked account, character still on that account, chat ban honoured, and guild/alliance membership verified against the binding. The world-chat case skips the membership check, which is correct for world chat.
  • Mention and markdown handling. ToGame strips mentions including @everyone/@here, converts custom emoji to :name:, drops markdown and collapses whitespace; ToDiscord escapes in the other direction and every send uses AllowedMentions.None.
  • Degradation without the privileged intent. ReadMessages defaults off and /say covers the same ground, which is the right default given Discord only grants MessageContent to verified bots.

The one finding I'd treat as blocking is the IChatMessageReceivedPlugIn bypass — a server's own chat filter not applying to the Discord side seems like something an operator would consider a bug. The flood-guard ordering is a close second because it's a two-line move for a real saving.


Generated by Claude Code

Comment thread src/Discord/ChatBridge/DiscordChatBridge.cs
Comment thread src/Discord/ChatBridge/DiscordChatBridge.cs Outdated
Comment thread src/Discord/ChatBridge/DiscordFloodGuard.cs Outdated
Comment thread src/Discord/ChatBridge/DiscordChatBridge.cs Outdated
Comment thread src/Discord/DiscordBot.ChatBridge.cs
Comment thread src/Discord/ChatBridge/GuildChatBindingStore.cs
Comment thread src/Discord/ChatBridge/DiscordChatText.cs
Comment thread src/Discord/DiscordBot.cs
Comment thread src/Discord/DiscordBot.ChatBridge.cs Outdated
…rms-m5

The migration AddGuildChatBinding is generated again, so that it follows
the migrations of master and of M4.

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

- Messages from Discord go through the new plugin point
  IExternalChatMessageReceivedPlugIn, e.g. for filters, because the
  IChatMessageReceivedPlugIn needs a sending player.
- The flood limit is checked first, so spamming doesn't cause database
  queries, and inactive users are forgotten.
- The linked members of a guild are determined with two queries instead of
  two per member (new batched queries in IPlayerContext).
- A hosted channel respects the limit of 500 permission overwrites and tells
  when not all members fit.
- The bindings are reloaded every tenth tick instead of every tick, and
  removing bindings without channel or server is refused.
- /say echoes exactly the message which the game got.
- The distributed admin panel publishes the unlinking of accounts.
- The intents are documented correctly.

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