Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
107 changes: 90 additions & 17 deletions docs-website/docs/server-features/discord.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ combined:
or rare drops, is posted into your channels. This works with simple *webhooks*,
or through the bot.
* **The bot:** it shows how many players are online, keeps a status message of
the game servers up to date, and answers slash commands like `/who`.
the game servers up to date, answers slash commands like `/who`, and sets up
the channels and roles of your Discord server with `/openmu setup`.

## Notifications

Expand Down Expand Up @@ -56,13 +57,17 @@ the configuration of the server process.

### With the bot

When the bot runs, it can post the notifications as well. Configure the
identifiers of the channels instead of webhooks, e.g.
`Discord__Bot__Channels__Events=123456789012345678`. A category with a channel of
the bot is posted by the bot, even when it also has a webhook.
When the bot runs, it posts the notifications into the channels of the
[layout](#setting-up-your-discord-server), e.g. the `Events` into `#events`. It
finds them by their name, so you don't need to configure anything.

To copy the identifier of a channel, enable the *Developer Mode* in the advanced
settings of Discord, then right-click the channel and select *Copy Channel ID*.
You can also choose other channels by their identifiers, e.g.
`Discord__Bot__Channels__Events=123456789012345678`. To copy the identifier of a
channel, enable the *Developer Mode* in the advanced settings of Discord, then
right-click the channel and select *Copy Channel ID*.

A category with a channel of the bot is posted by the bot, otherwise by its
webhook.

## The bot

Expand All @@ -74,8 +79,9 @@ settings of Discord, then right-click the channel and select *Copy Channel ID*.
The bot doesn't need any of the *Privileged Gateway Intents*.
3. Under *OAuth2 → URL Generator*, select the scopes `bot` and
`applications.commands`, and the permissions *View Channels*, *Send Messages*,
*Embed Links* and *Read Message History*. Open the generated URL to invite the
bot to your Discord server.
*Embed Links* and *Read Message History*. For [`/openmu setup`](#setting-up-your-discord-server),
also select *Manage Channels* and *Manage Roles*. Open the generated URL to
invite the bot to your Discord server.
4. Configure the token, e.g. as environment variable `Discord__Bot__Token`.
5. Restart the server. In the all-in-one deployment, the bot appears in the
server list of the admin panel.
Expand All @@ -94,17 +100,76 @@ In the distributed deployment, the bot runs in its own container, see
| `/rank` | The ten best characters by resets, master level and level |

The commands only show information; they don't change anything in the game.
Additionally, administrators of the Discord server can use
[`/openmu setup`](#setting-up-your-discord-server).

When you configure the identifier of your Discord server (`Discord__Bot__GuildId`),
the commands are available immediately. Otherwise, Discord may take up to an hour
to show them.

### Status

The presence of the bot shows how many players are online. When you configure a
status channel (`Discord__Bot__StatusChannelId`), the bot keeps a message with
the status of the game servers up to date in it. Use a channel in which only the
bot writes; it finds its message again after a restart.
The presence of the bot shows how many players are online. In the status channel
of the layout (`#server-status`), or in the one which you configure
(`Discord__Bot__StatusChannelId`), the bot keeps a message with the status of the
game servers up to date. Use a channel in which only the bot writes; it finds its
message again after a restart.

### Setting up your Discord server

The bot can set up your Discord server: run `/openmu setup` as administrator of
the Discord server. It creates the following roles, categories and channels:

| Category | Channels |
|---|---|
| Info | `#announcements` (notices of game masters), `#events` (events and castle siege), `#server-status` |
| Community | `#world-news`, `#world-chat`, `#general` |
| Staff | `#staff-alerts`, `#gm-commands` — only visible to the role *GM* |

The roles are *GM* and *Linked Player*. Only the bot can write into the
channels for notifications and the status. `#announcements` becomes an
*Announcement channel* on community servers, so that other Discord servers,
e.g. the ones of guilds, can follow it.

You can run the command again at any time: what already exists is adopted by
its name and nothing is created twice. Nothing is deleted or renamed, and only
the permissions which the layout defines are changed. So you can also start
with an existing Discord server, or with a server template (see below), and
let the command add what's missing. The answer of the command shows what was
created, what already existed and what failed.

The bot finds the channels by their name: when you rename one, configure its
identifier instead, as described above. When the bot is in more than one
Discord server, it posts into the channels of the one which you configure as
`Discord__Bot__GuildId`.

#### Your own layout

The layout is defined in a JSON file. To use your own, copy the
[default layout](https://github.com/MUnique/OpenMU/blob/master/src/Discord/Provisioning/DefaultLayout.json),
adapt it and configure its path as `Discord__Bot__LayoutFile`. Each role,
category and channel has a key, which has to be unique, and a name. Channels can
have these properties:

| Property | Description |
|---|---|
| `topic` | The topic of the channel. |
| `isAnnouncement` | Creates an announcement channel, on community servers. |
| `isReadOnly` | Only the bot can write into the channel. |
| `notifications` | The categories of notifications which are posted into the channel, e.g. `[ "Events", "CastleSiege" ]`. |
| `showsStatus` | The bot shows the status of the game servers in the channel. |

With `visibleTo`, a category is only visible to the listed roles, e.g.
`"visibleTo": [ "gm" ]`.

#### Server templates

Discord can copy the structure of a Discord server with a *server template*:
In the settings of a set-up Discord server, open *Server Template*, create a
template and share its link. Everybody who opens the link creates a new Discord
server with the same channels, roles and permissions, but without messages and
members. After inviting the bot to it, `/openmu setup` adopts everything and
reports that nothing was missing.

## Settings

Expand All @@ -119,9 +184,10 @@ the parts are separated by two underscores.
| `EventAnnouncingServerIds__0`, `__1`, … | all | The identifiers of the game servers which announce mini games and invasions. When several game servers run the same events, set it to one of them to avoid duplicate messages. |
| `MaximumQueuedMessages` | `100` | The number of messages per channel which can wait to be sent. When there are more, the oldest ones are dropped. |
| `Bot__Token` | — | The token of the bot. Without it, the bot doesn't run. |
| `Bot__GuildId` | — | The identifier of your Discord server, for the slash commands. |
| `Bot__StatusChannelId` | — | The identifier of the channel with the status message. |
| `Bot__Channels__<Category>` | — | The identifier of the channel per category, into which the bot posts the notifications. |
| `Bot__GuildId` | — | The identifier of your Discord server, for the slash commands and the channels of the layout. |
| `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__StatusUpdateInterval` | `00:01:00` | How often the status message and the presence are updated. |

## Good to know
Expand All @@ -139,4 +205,11 @@ the parts are separated by two underscores.
trying again and again.
* Make your notices channel an *Announcement channel*: other Discord servers,
e.g. the ones of guilds, can then *follow* it and get the messages copied into
their own channel.
their own channel. `/openmu setup` does this on community servers.
* Until the bot is connected to Discord, which takes a few seconds after the
start, notifications for the channels of the layout are posted through the
webhook of their category, if there is one.

Private staff channels are skipped if their required roles or permissions cannot
be set. Fix the reported permissions and run setup again. Public channels with
staff channel names are not used automatically for notifications.
131 changes: 126 additions & 5 deletions src/Discord/DiscordBot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,42 @@ namespace MUnique.OpenMU.Discord;
using global::Discord.WebSocket;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Discord.Properties;
using MUnique.OpenMU.Discord.Provisioning;
using MUnique.OpenMU.Interfaces;

/// <summary>
/// The Discord bot. It shows the status of the game servers, answers the slash commands
/// and can post messages, e.g. the game events.
/// The Discord bot. It shows the status of the game servers, answers the slash commands,
/// can post messages, e.g. the game events, and sets up the Discord server.
/// </summary>
/// <remarks>
/// It only needs the <see cref="GatewayIntents.Guilds"/> intent, and no privileged one,
/// because it reacts on slash commands instead of reading messages.
/// </remarks>
public sealed class DiscordBot : IManageableServer, IDiscordMessenger, IAsyncDisposable
{
/// <summary>
/// The name of the command which administrates the integration.
/// </summary>
internal const string AdministrationCommandName = "openmu";

/// <summary>
/// The name of the sub command which sets up the Discord server.
/// </summary>
internal const string SetupCommandName = "setup";

private readonly DiscordBotSettings _settings;
private readonly DiscordCommands _commands;
private readonly DiscordStatusFormatter _statusFormatter;
private readonly IDiscordGameDataProvider _data;
private readonly DiscordServerLayout _layout;
private readonly DiscordServerProvisioner _provisioner;
private readonly ILogger<DiscordBot> _logger;
private readonly SemaphoreSlim _lifecycleLock = new(1, 1);

private DiscordSocketClient? _client;
private TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
private CancellationTokenSource? _statusLoopCancellation;
private DiscordChannelRouting _routing;
private ulong? _statusMessageId;
private ServerState _serverState = ServerState.Stopped;
private int _isDisposed;
Expand All @@ -44,14 +58,19 @@ public sealed class DiscordBot : IManageableServer, IDiscordMessenger, IAsyncDis
/// <param name="commands">The slash commands.</param>
/// <param name="statusFormatter">The formatter of the status.</param>
/// <param name="data">The provider of the data of the game.</param>
/// <param name="layout">The layout of the Discord server.</param>
/// <param name="provisioner">The provisioner, which sets up the Discord server.</param>
/// <param name="logger">The logger.</param>
public DiscordBot(DiscordBotSettings settings, DiscordCommands commands, DiscordStatusFormatter statusFormatter, IDiscordGameDataProvider data, ILogger<DiscordBot> logger)
public DiscordBot(DiscordBotSettings settings, DiscordCommands commands, DiscordStatusFormatter statusFormatter, IDiscordGameDataProvider data, DiscordServerLayout layout, DiscordServerProvisioner provisioner, ILogger<DiscordBot> logger)
{
this._settings = settings;
this._commands = commands;
this._statusFormatter = statusFormatter;
this._data = data;
this._layout = layout;
this._provisioner = provisioner;
this._logger = logger;
this._routing = DiscordChannelRouting.Create(settings, layout, new Dictionary<string, ulong>());
}

/// <inheritdoc />
Expand Down Expand Up @@ -150,6 +169,12 @@ public async ValueTask ShutdownAsync()
/// <inheritdoc />
Task Microsoft.Extensions.Hosting.IHostedService.StopAsync(CancellationToken cancellationToken) => this.ShutdownAsync().AsTask();

/// <inheritdoc />
/// <remarks>
/// The channels of the layout are known after the bot connected to Discord.
/// </remarks>
public ulong? GetChannelId(DiscordChannelCategory category) => this._routing.GetChannelId(category);

/// <inheritdoc />
public async Task SendAsync(ulong channelId, IReadOnlyList<DiscordEmbed> embeds, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -231,6 +256,7 @@ private async Task OnReadyAsync(DiscordSocketClient client)
this._logger.LogError(ex, "The slash commands of the Discord bot couldn't be registered.");
}

this.UpdateRouting(client);
this.ServerState = ServerState.Started;
this._ready.TrySetResult();

Expand All @@ -254,7 +280,7 @@ private async Task RegisterCommandsAsync(DiscordSocketClient client)
}

return (ApplicationCommandProperties)builder.Build();
}).ToArray();
}).Append(this.CreateAdministrationCommand()).ToArray();

if (this._settings.GuildId is { } guildId && client.GetGuild(guildId) is { } guild)
{
Expand All @@ -271,8 +297,28 @@ private async Task RegisterCommandsAsync(DiscordSocketClient client)
}
}

private ApplicationCommandProperties CreateAdministrationCommand()
{
return new SlashCommandBuilder()
.WithName(AdministrationCommandName)
.WithDescription(this._commands.Text(nameof(Resources.Command_OpenMu_Description)))
.WithDefaultMemberPermissions(GuildPermission.Administrator)
.WithContextTypes(InteractionContextType.Guild)
.AddOption(new SlashCommandOptionBuilder()
.WithName(SetupCommandName)
.WithDescription(this._commands.Text(nameof(Resources.Command_Setup_Description)))
.WithType(ApplicationCommandOptionType.SubCommand))
.Build();
}

private async Task OnSlashCommandExecutedAsync(SocketSlashCommand command)
{
if (command.Data.Name == AdministrationCommandName)
{
await this.SetUpDiscordServerAsync(command).ConfigureAwait(false);
return;
}

try
{
// The data might come from the database, which can take longer than Discord waits for an answer.
Expand All @@ -284,7 +330,63 @@ private async Task OnSlashCommandExecutedAsync(SocketSlashCommand command)
catch (Exception ex)
{
this._logger.LogError(ex, "Error when answering the Discord command {command}.", command.Data.Name);
await this.AnswerFailureAsync(command, false).ConfigureAwait(false);
}
}

private async Task SetUpDiscordServerAsync(SocketSlashCommand command)
{
try
{
await command.DeferAsync(ephemeral: true).ConfigureAwait(false);

// The command is only visible to administrators by default, but this can be changed in the settings of the Discord server.
if (this._client is not { } client
|| command.GuildId is not { } guildId
|| client.GetGuild(guildId) is not { } guild
|| command.User is not SocketGuildUser { GuildPermissions.Administrator: true })
{
await command.FollowupAsync(embed: ToDiscordEmbed(this._commands.CreateAdministratorRequiredAnswer()), ephemeral: true).ConfigureAwait(false);
return;
}

this._logger.LogInformation("The Discord server {guild} ({guildId}) is set up by {user}.", guild.Name, guild.Id, command.User.Username);
var result = await this._provisioner.ProvisionAsync(new SocketGuildServer(guild), this._layout).ConfigureAwait(false);
if (this.GetRoutingGuild(client)?.Id == guild.Id)
{
// The cache of the client might not know the created channels yet.
this._routing = DiscordChannelRouting.Create(this._settings, this._layout, result.ChannelIds);
}

await command.FollowupAsync(embed: ToDiscordEmbed(this._commands.CreateSetupReport(result)), ephemeral: true, allowedMentions: AllowedMentions.None).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error when setting up the Discord server.");
Comment thread
sven-n marked this conversation as resolved.
await this.AnswerFailureAsync(command, true).ConfigureAwait(false);
}
}

/// <summary>
/// Gets the Discord server whose channels of the layout receive the notifications and the status:
/// The configured one, or the only one which the bot is in.
/// </summary>
private SocketGuild? GetRoutingGuild(DiscordSocketClient client)
{
if (this._settings.GuildId is { } guildId)
{
return client.GetGuild(guildId);
}

return client.Guilds.Count == 1 ? client.Guilds.First() : null;
}

private void UpdateRouting(DiscordSocketClient client)
{
var channelIds = this.GetRoutingGuild(client) is { } guild
? DiscordServerProvisioner.FindChannels(this._layout, SocketGuildServer.GetChannels(guild))
: new Dictionary<string, ulong>();
this._routing = DiscordChannelRouting.Create(this._settings, this._layout, channelIds);
}

private async Task RunStatusLoopAsync(CancellationToken cancellationToken)
Expand Down Expand Up @@ -314,10 +416,12 @@ private async Task UpdateStatusAsync()

try
{
// Channels might have been created, renamed or deleted in the meantime.
this.UpdateRouting(client);
var servers = this._data.GetGameServers();
await client.SetCustomStatusAsync(this._statusFormatter.CreatePresence(servers)).ConfigureAwait(false);

if (this._settings.StatusChannelId is { } channelId
if (this._routing.StatusChannelId is { } channelId
&& await this.GetMessageChannelAsync(channelId).ConfigureAwait(false) is { } channel)
{
var embed = ToDiscordEmbed(this._statusFormatter.CreateStatus(servers, DateTime.UtcNow));
Expand Down Expand Up @@ -360,6 +464,23 @@ private async Task UpdateStatusAsync()
bool IsStatusMessage(IUserMessage m) => m.Author.Id == client.CurrentUser.Id && m.Embeds.FirstOrDefault()?.Title == this._statusFormatter.StatusTitle;
}

/// <summary>
/// Answers a deferred command with a failure, so that the user doesn't wait for an answer which never comes.
/// </summary>
private async Task AnswerFailureAsync(SocketSlashCommand command, bool ephemeral)
{
try
{
var answer = new DiscordEmbed(command.Data.Name, this._commands.Text(nameof(Resources.Command_Failed)), DiscordCommands.AnswerColor, DateTime.UtcNow, null);
await command.FollowupAsync(embed: ToDiscordEmbed(answer), ephemeral: ephemeral, allowedMentions: AllowedMentions.None).ConfigureAwait(false);
}
catch (Exception ex)
{
// E.g. the interaction expired or wasn't deferred.
this._logger.LogDebug(ex, "The failure of the Discord command {command} couldn't be answered.", command.Data.Name);
}
}

private async Task<IMessageChannel?> GetMessageChannelAsync(ulong channelId)
{
if (this._client is not { } client)
Expand Down
Loading
Loading