Skip to content

feat(discord): Discord bot with status and read-only slash commands - #1156

Open
sven-n wants to merge 8 commits into
claude/sweet-cray-w8crmsfrom
claude/sweet-cray-w8crms-m2
Open

sven-n wants to merge 8 commits into
claude/sweet-cray-w8crmsfrom
claude/sweet-cray-w8crms-m2

Conversation

@sven-n

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

Copy link
Copy Markdown
Member

Implements milestone M2 of the Discord integration epic (#1097): the Discord bot, in both deployments.

Closes #1100

Stacked on #1155 (M1). The base is the M1 branch, so this diff only shows M2. When #1155 is merged, GitHub retargets this PR to master.

Changes

Queries the bot needs (9f8af3e3, 9f642ef2, b3f5339f)

  • IFriendServer.GetOnlineServerIdAsync: the friend server already tracks the online state of all characters. Invisible characters appear offline, like they do in the game. Dapr glue: FriendServer client + FriendServerController endpoint.
    • Why not ask the game servers: all game servers share the Dapr app id game-server, so a request to one specific game server isn't possible in the distributed deployment.
  • IScheduledPlugIn / PeriodicTaskConfiguration.GetNextStartUtc: the periodic task plugins (mini games, invasions, happy hour, …) provide their next start, in the server's time zone. UpcomingEvents.Get collects the active ones within a time span.
  • IPlayerContext.GetCharacterRankingAsync / GetCharacterSummaryAsync (EF + in-memory): ordered by resets, master level, level, experience; bots, templates and game masters aren't ranked. The level attribute definitions are passed in, because they're defined by the game logic.
    • The EF queries are built by separate methods, so tests check that they translate to SQL. That test caught an untranslatable ordering in my first version.

The bot (4815e5bc)

  • DiscordBot (Discord.Net 3.20.2) is an IManageableServer:
    • Presence: shows the number of online players.
    • Status message: kept up to date in a configured channel. After a restart, the bot finds its last message again, so nothing is stored.
    • Slash commands: /online, /who, /guild, /events, /rank. They're read-only and answered in the configured language. The answer is deferred, because the data can come from the database.
    • Posting: can post the game events into channels, instead of the webhooks.
    • Intents: needs only the Guilds intent; no privileged intent, because it doesn't read messages.
  • Command logic (DiscordCommands) and status (DiscordStatusFormatter) are independent of Discord.Net. They get their data from IDiscordGameDataProvider, so they're tested without Discord. DiscordGameDataProvider implements it with the interfaces of the servers and the database, so it works in-process and distributed.
  • The M1 webhook queue became the base class DiscordMessageQueue; DiscordBotChannelSender uses it to post through the bot.
  • Renames of M1 types: DiscordWebhookSettings → DiscordSettings (with a Bot section), DiscordWebhookNotifier → DiscordNotifier.
  • ServerType.Discord appended to the enum.

Hosting (5bb083d8)

  • AddDiscord (in the Discord project) adds the bot when a token is configured, and the notifier when webhooks or bot channels are configured. Both hosts use it.
  • All-in-one: the bot is listed with the other servers (e.g. in the admin panel).
  • Distributed: the new Dapr host Discord.Host:
    • subscribes to the GameEventAsync topic and runs the bot and the notifier, so M1's notifications now work in the distributed deployment, too;
    • is in docker-compose.yml behind the profile discord;
    • is in the Docker publish matrix as openmu-discord.
  • The bot stops when Discord rejects its token, instead of retrying forever (found in the smoke test below). Disposing it twice is safe; the DI container does that, because the bot is singleton and hosted service.

Docs (d2921169)

  • Server features → Discord: creating and inviting the bot (scopes, permissions), slash commands, status, posting through the bot, all settings.
  • Distributed deployment: how to start the Discord container.

Notes for review

  • Docker Hub: the repository munique/openmu-discord must exist before the next release, otherwise that matrix job fails. The other images aren't affected (fail-fast: false).
  • Guild lookups: /guild for a guild without online members loads its container in the guild server, which stays loaded until a member logs in and out again. The castle siege lookups behave the same way. It's bounded by the number of guilds.
  • Distributed time zone: the distributed host uses UTC for the event schedules. The Dapr game servers don't set a server time zone either, so the times match.
  • Admin panel: the bot appears in the server list of the all-in-one admin panel, without start/stop buttons, like the chat server. In the distributed admin panel it doesn't appear, because it doesn't publish its state.

Testing

  • dotnet test tests/MUnique.OpenMU.Tests -p:ci=true: all 1678 tests pass (31 new):
    • friend server online state;
    • next start and upcoming events, incl. time zones;
    • ranking and summary, in-memory plus SQL translation for EF;
    • commands, in English and German, incl. not found and error cases;
    • status and presence;
    • data provider (servers, characters, guilds incl. invisible members, events, ranking);
    • notifier posting through the bot instead of the webhook;
    • DI registration, which found the double-dispose bug.
  • Discord.Host, Startup, CentralServer.Host build without errors or new warnings.
  • Smoke test: the all-in-one server in demo mode with an invalid bot token. The server starts and the bot connects to Discord's API. It gets a 401, logs one clear error and stops.
  • Not tested: with a real bot token in a Discord server, so the commands, presence and status message haven't been seen in a real Discord client yet. Not tested with docker compose either.

🤖 Generated with Claude Code

https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ


Generated by Claude Code

claude added 6 commits October 8, 2026 20:39
IFriendServer.GetOnlineServerIdAsync returns the game server on which a
character is online. Invisible characters appear offline, like they do
for other players. The friend server already tracks the online state of
all characters, so this works for the all-in-one and the distributed
deployment. It's needed for the /who command of the Discord bot.

Refs #1100

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ
The periodic task plugins (mini games, invasions, happy hour, ...) now
provide their next start time according to their timetable, in the time
zone of the server. UpcomingEvents collects the ones of the active plugins
which start within a time span, e.g. for the /events command of the
Discord bot.

Refs #1100

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ
IPlayerContext.GetCharacterRankingAsync returns the best characters,
ordered by resets, master level, level and experience. Characters of bots,
templates and game masters aren't ranked. GetCharacterSummaryAsync returns
the same summary (class, level, master level, resets) for one character.
The attribute definitions of the levels are passed in, because they are
defined by the game logic.

The Entity Framework queries are built by separate methods, so that tests
can check that they translate to SQL.

Refs #1100

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

The new DiscordBot (based on Discord.Net) runs as manageable server:

* Its presence shows the number of online players, and it keeps a message
  with the status of the game servers up to date in a configured channel.
  It finds its message again after a restart, so nothing has to be stored.
* It answers the slash commands /online, /who, /guild, /events and /rank,
  in the configured language. They only show information.
* It can post the game events into channels, instead of webhooks.

It only needs the Guilds intent, because it reacts on slash commands
instead of reading messages.

The commands and the status are formatted by separate classes, which get
their data from IDiscordGameDataProvider, so that they can be tested
without Discord. DiscordGameDataProvider implements it with the interfaces
of the servers and the database, so it works in-process as well as with
the servers in separate processes.

The queue of the webhooks became the base class DiscordMessageQueue, which
is also used to post through the bot. The settings are now DiscordSettings
with a Bot section, and DiscordWebhookNotifier is now DiscordNotifier.

Refs #1100

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ
* The Discord project got AddDiscord, which adds the bot when a token is
  configured, and the notifier when webhooks or bot channels are
  configured. Both hosts use it.
* The all-in-one server lists the bot with the other servers, e.g. in the
  admin panel.
* The new Dapr host Discord.Host subscribes to the game events of the
  game servers and runs the bot and the notifier for the distributed
  deployment. It's part of the docker-compose file with the profile
  "discord" and of the published Docker images.
* The bot stops when Discord rejects its token, instead of trying again
  and again, and it can be disposed more than once.

Refs #1100

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ
The Discord page now describes how to create and invite the bot, its
slash commands, the status message, the channels into which it posts the
notifications, and all settings. The page of the distributed deployment
describes how to start its Discord container.

Refs #1100

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 8, 2026 •

Copy link
Copy Markdown

Deploying openmudocs with  Cloudflare Pages  Cloudflare Pages

Latest commit: a404a0f
Status: ✅  Deploy successful!
Preview URL: https://34ebfc45.openmudocs.pages.dev
Branch Preview URL: https://claude-sweet-cray-w8crms-m2.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.

The lifecycle handling in DiscordBot is careful — the _lifecycleLock around start/shutdown, the idempotent DisposeAsync for the singleton-plus-hosted-service case, stopping the client on an Unauthorized from the log callback so an invalid token doesn't reconnect in a loop, and deferring every interaction before touching the database. I checked the Task.Run in OnLogAsync for a deadlock against StartAsync holding the lock and it's fine, since client.StartAsync returns before the gateway connects.

Three comments inline. The status-message one is the only behavioural issue; the other two are cost and conventions.

Two smaller things not worth their own threads:

  • RegisterCommandsAsync falls back to global registration when _settings.GuildId is set but client.GetGuild(guildId) returns null at Ready time. The operator gets commands that take up to an hour to appear and no indication that their configured guild id was the problem — a warning in that branch would save some confusion.
  • /who matches the character name exactly (character.Name == characterName). That's consistent with the rest of the codebase (AccountRepository, the letter receiver lookup), so I'm not asking for a change — but this is a name typed by hand in a Discord client rather than sent by the game client, so case-insensitive matching would be kinder.

Generated by Claude Code

Comment thread src/Discord/DiscordBot.cs Outdated
Comment thread src/Persistence/EntityFramework/PlayerContext.cs
Comment thread src/Discord/DiscordServiceCollectionExtensions.cs Outdated
# Conflicts:
#	src/Discord/DiscordWebhookSender.cs
#	src/Startup/DiscordServiceCollectionExtensions.cs
…cache the ranking

The bot only edits its own status message, identified by the status title,
so game events in the same channel aren't overwritten; it warns when the
status channel is also configured for events. The ranking for /rank is
cached for two minutes, because every member of the Discord server can
request it. The bot warns when it isn't on the configured Discord server.

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