Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9e66c72
feat(game-logic): publish events for direct messages and the next cas…
claude Oct 9, 2026
05634b0
feat(persistence): store the opted-in notifications of account links
claude Oct 9, 2026
18ab252
feat(discord): add game master commands, direct messages and schedule…
claude Oct 9, 2026
0e66685
docs(discord): document the commands for game masters, direct message…
claude Oct 9, 2026
8da5427
Merge branch 'claude/sweet-cray-w8crms-m5' into claude/sweet-cray-w8c…
claude Oct 18, 2026
207cf57
fix(discord): query the friends at once, and make announcements robust
claude Oct 18, 2026
e3e0875
Merge branch 'claude/sweet-cray-w8crms-m5' into claude/sweet-cray-w8c…
claude Oct 20, 2026
fd2b876
Merge branch 'claude/sweet-cray-w8crms-m5' into claude/sweet-cray-w8c…
claude Oct 21, 2026
7fcf875
Merge branch 'claude/sweet-cray-w8crms-m5' into claude/sweet-cray-w8c…
claude Oct 21, 2026
d335859
feat(discord): post the announcements of game masters into Discord, too
claude Oct 21, 2026
0560239
fix(discord): bound and cancel background notification work
sven-n Oct 22, 2026
7a2fcff
refactor(discord): address static analysis regressions
sven-n Oct 22, 2026
ca5566b
style(discord): wrap direct message request arguments
sven-n Oct 22, 2026
4bbbe0d
fix(discord): resolve static analysis regressions
sven-n Oct 22, 2026
8ca3dcf
docs(discord): fix Markdown lint regressions and settings table
sven-n Oct 22, 2026
c6a0fde
test(logging): wait for advanced editor to render
sven-n Oct 22, 2026
ada25ea
Merge master into Discord announcement settings
sven-n Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs-website/docs/server-features/discord.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ combined:
| `CastleSiege` | Guild registration, the start and the end of the battle, and the new owner of the castle |
| `WorldNews` | Boss kills, excellent and ancient items which monsters dropped, characters which reach a level milestone |
| `Notices` | Global notices which game masters send with the `!` chat prefix |
| `StaffAlerts` | The commands which game masters used in Discord, see [Commands for game masters](#commands-for-game-masters) |

Each category is posted into its own channel. A category without channel isn't
posted.
Expand Down Expand Up @@ -104,6 +105,8 @@ In the distributed deployment, the bot runs in its own container, see
| `/character <name>` | Selects the character of the account, as which the player appears |
| `/say <message>` | Sends a message to the chat of the game which is bound to the channel, see [Chat bridge](#chat-bridge) |
| `/guildchat bind`, `create`, `unbind` | Binds the chat of a guild or alliance to a channel, see [Chat bridge](#chat-bridge) |
| `/notify <type> <enabled>` | Turns [direct messages](#direct-messages) on or off |
| `/announce <text>`, `/kick <character>`, `/ban <character>` | [Commands for game masters](#commands-for-game-masters) |

The commands only show information; they don't change anything in the game.
Additionally, players can [link their account](#linking-accounts) with
Expand Down Expand Up @@ -273,6 +276,45 @@ Without the intent, players use `/say <message>`. Discord only approves the
intent for verified bots, which are in more than 100 Discord servers, with a
reason.

## Commands for game masters

Game masters can use these commands in Discord:

| Command | Effect |
|---|---|
| `/announce <text>` | Sends the text as golden notice to all players of all game servers, and posts it into the channel of the `Notices` category, e.g. `#announcements`. |
| `/kick <character>` | Disconnects the character from the game. |
| `/ban <character>` | Bans the account of the character, which has to be online, and disconnects it. |

They need the role *GM* of the [layout](#setting-up-your-discord-server) on your
Discord server, **and** a [linked account](#linking-accounts) whose selected
character (`/character`) is a game master. So a Discord role alone isn't
enough. Every use is logged and posted into `#staff-alerts`, or the channel of
the `StaffAlerts` category.

## Direct messages

Linked players can get direct messages from the bot. They are off by default,
and each player turns them on with `/notify` in Discord, or with
`/discord notify <type>` in the game, which turns a type on or off:

| Type | Direct message |
|---|---|
| `login` | Somebody tried to log into the account with the correct password, while it was logged in. |
| `letter` | A character of the account received a letter. |
| `friend` | A friend entered the game. Friends who are invisible in the game don't cause a message. |

The bot can only send direct messages to users who share a Discord server with
it, and who allow direct messages from its members.

## Scheduled events

With `Bot__CreateScheduledEvents` set to `true`, the bot creates a *scheduled
event* on your Discord server for the next castle siege, so that players can
mark it as interesting and get reminded by Discord. It's updated when the castle
siege changes its state, which requires the *Game Event Publisher* plugin with
castle siege events. The bot needs the permission to create events.

## Settings

All settings are part of the `Discord` section of the configuration, so they can
Expand All @@ -290,6 +332,7 @@ the parts are separated by two underscores.
| `Bot__StatusChannelId` | the layout | The identifier of the channel with the status message. |
| `Bot__Channels__<Category>` | the layout | The identifier of the channel per category, into which the bot posts the notifications. |
| `Bot__LayoutFile` | the default | The path of a JSON file with your own [layout](#your-own-layout). |
| `Bot__CreateScheduledEvents` | `false` | Creates a [scheduled event](#scheduled-events) for the next castle siege. |
| `Bot__StatusUpdateInterval` | `00:01:00` | How often the status message and the presence are updated. |
| `Bot__ChatBridge__BindingMode` | `Both` | Where the chats of guilds can be bound: `Both`, `HostedOnly` (only channels on your Discord server) or `GuildOwnedOnly` (only on Discord servers of guilds). |
| `Bot__ChatBridge__AllowedDiscordServerIds__0`, `__1`, … | all | The Discord servers of guilds, on which chats can be bound. |
Expand Down Expand Up @@ -326,3 +369,7 @@ Commands are registered globally so guild-owned Discord servers can use `/link`,
guild-scoped registration for immediate availability. Binding and unbinding as a
guild master require that the selected character still belongs to the linked
account.

Direct messages and scheduled-event updates use a bounded queue. Stopping or
reconfiguring the bot cancels pending work, including notifications waiting for
a gateway connection.
6 changes: 6 additions & 0 deletions src/DataModel/Entities/AccountExternalLink.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ public class AccountExternalLink
/// </summary>
public DateTime? LinkedAt { get; set; }

/// <summary>
/// Gets or sets the types of notifications which the user receives in the external service.
/// They are opt-in, so there are none by default.
/// </summary>
public AccountNotificationTypes Notifications { get; set; }

/// <summary>
/// Gets or sets the hash of the pending one-time code; <c>null</c>, if there is none.
/// </summary>
Expand Down
32 changes: 32 additions & 0 deletions src/DataModel/Entities/AccountNotificationTypes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// <copyright file="AccountNotificationTypes.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.DataModel.Entities;

/// <summary>
/// The types of notifications which a player receives in an external service, e.g. as direct message in Discord.
/// </summary>
[Flags]
public enum AccountNotificationTypes
{
/// <summary>
/// No notifications.
/// </summary>
None = 0,

/// <summary>
/// Somebody tried to log into the account while it was logged in.
/// </summary>
LoginAttempt = 1,

/// <summary>
/// A character of the account received a letter.
/// </summary>
LetterReceived = 2,

/// <summary>
/// A friend of a character of the account entered the game.
/// </summary>
FriendOnline = 4,
}
44 changes: 44 additions & 0 deletions src/Discord/DiscordAccountCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ namespace MUnique.OpenMU.Discord;

using System.Globalization;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.Discord.Properties;
using MUnique.OpenMU.GameLogic.AccountLinking;

Expand Down Expand Up @@ -78,6 +79,49 @@ public async ValueTask<DiscordCommandAnswer> ExecuteAsync(string commandName, st
}
}

/// <summary>
/// Turns a type of direct messages on or off.
/// </summary>
/// <param name="userId">The identifier of the Discord user.</param>
/// <param name="typeKeyword">The keyword of the type, see <see cref="AccountNotificationKeywords"/>.</param>
/// <param name="enabled">A value indicating whether the direct messages are turned on.</param>
/// <returns>The answer.</returns>
public async ValueTask<DiscordEmbed> SetNotificationAsync(ulong userId, string? typeKeyword, bool enabled)
{
try
{
var notifications = await this._linkService.SetNotificationByUserAsync(Provider, userId.ToString(CultureInfo.InvariantCulture), AccountNotificationKeywords.Parse(typeKeyword), enabled).ConfigureAwait(false);
if (notifications is not { } types)
{
return this.Answer(nameof(Resources.Link_NotLinked)).Embed;
}

var names = new List<string>();
if (types.HasFlag(AccountNotificationTypes.LoginAttempt))
{
names.Add(DiscordCommands.GetText(this._culture, nameof(Resources.Notify_Type_Login)));
}

if (types.HasFlag(AccountNotificationTypes.LetterReceived))
{
names.Add(DiscordCommands.GetText(this._culture, nameof(Resources.Notify_Type_Letter)));
}

if (types.HasFlag(AccountNotificationTypes.FriendOnline))
{
names.Add(DiscordCommands.GetText(this._culture, nameof(Resources.Notify_Type_Friend)));
}

var list = names.Count > 0 ? string.Join(", ", names) : DiscordCommands.GetText(this._culture, nameof(Resources.Notify_None));
return this.Answer(nameof(Resources.Notify_Changed), [], list).Embed;
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error when changing the direct messages of the Discord user {userId}.", userId);
return this.Answer(nameof(Resources.Command_Failed)).Embed;
}
}

private async ValueTask<DiscordCommandAnswer> LinkAsync(string code, ulong userId, string userName)
{
var userIdText = userId.ToString(CultureInfo.InvariantCulture);
Expand Down
104 changes: 104 additions & 0 deletions src/Discord/DiscordBackgroundQueue.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// <copyright file="DiscordBackgroundQueue.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.Discord;

using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;

/// <summary>
/// Runs bounded background work for one connection lifetime of the bot.
/// </summary>
internal sealed class DiscordBackgroundQueue : IAsyncDisposable
{
private readonly Channel<Func<CancellationToken, Task>> _queue;
private readonly CancellationTokenSource _cancellation = new();
private readonly ILogger _logger;
private readonly Task _worker;
private int _isDisposed;

/// <summary>
/// Initializes a new instance of the <see cref="DiscordBackgroundQueue"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="capacity">The maximum number of pending operations.</param>
internal DiscordBackgroundQueue(ILogger logger, int capacity = 100)
{
this._logger = logger;
this._queue = Channel.CreateBounded<Func<CancellationToken, Task>>(new BoundedChannelOptions(capacity) { SingleReader = true });
this._worker = Task.Run(this.RunAsync);
}

/// <inheritdoc />
ValueTask IAsyncDisposable.DisposeAsync() => this.DisposeAsync();

/// <summary>
/// Queues an operation, or rejects it when the queue is full or stopped.
/// </summary>
/// <param name="operation">The operation.</param>
/// <returns>Whether the operation was queued.</returns>
internal bool Enqueue(Func<CancellationToken, Task> operation)
{
if (this._queue.Writer.TryWrite(operation))
{
return true;
}

this._logger.LogWarning("A Discord background notification was dropped because the queue is full or stopped.");
return false;
}

/// <summary>
/// Cancels the connection's operations and waits for the worker to stop.
/// </summary>
/// <returns>The asynchronous shutdown operation.</returns>
internal async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref this._isDisposed, 1) != 0)
{
return;
}

this._queue.Writer.TryComplete();
await this._cancellation.CancelAsync().ConfigureAwait(false);
try
{
await this._worker.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
}
catch (TimeoutException)
{
this._logger.LogWarning("A Discord background operation did not finish within the shutdown timeout.");
}
finally
{
this._cancellation.Dispose();
}
}

private async Task RunAsync()
{
var cancellationToken = this._cancellation.Token;
try
{
await foreach (var operation in this._queue.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await operation(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
this._logger.LogWarning(ex, "A Discord background notification failed.");
}
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Shutdown cancels waits for Ready as well as requests to Discord.
}
}
}
Loading
Loading