diff --git a/docs-website/docs/server-features/discord.md b/docs-website/docs/server-features/discord.md index 2ffe77df81..65831dba50 100644 --- a/docs-website/docs/server-features/discord.md +++ b/docs-website/docs/server-features/discord.md @@ -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 @@ -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 @@ -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. @@ -94,6 +100,8 @@ 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 @@ -101,10 +109,67 @@ 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 @@ -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__` | — | 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__` | 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 @@ -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. diff --git a/src/Discord/DiscordBot.cs b/src/Discord/DiscordBot.cs index 0d21060518..417c869161 100644 --- a/src/Discord/DiscordBot.cs +++ b/src/Discord/DiscordBot.cs @@ -11,11 +11,12 @@ 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; /// -/// 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. /// /// /// It only needs the intent, and no privileged one, @@ -23,16 +24,29 @@ namespace MUnique.OpenMU.Discord; /// public sealed class DiscordBot : IManageableServer, IDiscordMessenger, IAsyncDisposable { + /// + /// The name of the command which administrates the integration. + /// + internal const string AdministrationCommandName = "openmu"; + + /// + /// The name of the sub command which sets up the Discord server. + /// + 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 _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; @@ -44,14 +58,19 @@ public sealed class DiscordBot : IManageableServer, IDiscordMessenger, IAsyncDis /// The slash commands. /// The formatter of the status. /// The provider of the data of the game. + /// The layout of the Discord server. + /// The provisioner, which sets up the Discord server. /// The logger. - public DiscordBot(DiscordBotSettings settings, DiscordCommands commands, DiscordStatusFormatter statusFormatter, IDiscordGameDataProvider data, ILogger logger) + public DiscordBot(DiscordBotSettings settings, DiscordCommands commands, DiscordStatusFormatter statusFormatter, IDiscordGameDataProvider data, DiscordServerLayout layout, DiscordServerProvisioner provisioner, ILogger 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()); } /// @@ -150,6 +169,12 @@ public async ValueTask ShutdownAsync() /// Task Microsoft.Extensions.Hosting.IHostedService.StopAsync(CancellationToken cancellationToken) => this.ShutdownAsync().AsTask(); + /// + /// + /// The channels of the layout are known after the bot connected to Discord. + /// + public ulong? GetChannelId(DiscordChannelCategory category) => this._routing.GetChannelId(category); + /// public async Task SendAsync(ulong channelId, IReadOnlyList embeds, CancellationToken cancellationToken) { @@ -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(); @@ -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) { @@ -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. @@ -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."); + await this.AnswerFailureAsync(command, true).ConfigureAwait(false); + } + } + + /// + /// 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. + /// + 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(); + this._routing = DiscordChannelRouting.Create(this._settings, this._layout, channelIds); } private async Task RunStatusLoopAsync(CancellationToken cancellationToken) @@ -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)); @@ -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; } + /// + /// Answers a deferred command with a failure, so that the user doesn't wait for an answer which never comes. + /// + 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 GetMessageChannelAsync(ulong channelId) { if (this._client is not { } client) diff --git a/src/Discord/DiscordBotSettings.cs b/src/Discord/DiscordBotSettings.cs index 5827aa95bd..8845273964 100644 --- a/src/Discord/DiscordBotSettings.cs +++ b/src/Discord/DiscordBotSettings.cs @@ -18,20 +18,29 @@ public class DiscordBotSettings /// Gets or sets the identifier of the Discord server (guild) of the game server. /// The slash commands are registered for it, which makes them available immediately. /// Without it, they are registered globally, which can take up to an hour. + /// The bot posts into the channels of the layout of this Discord server; without it, of the only Discord server which the bot is in. /// public ulong? GuildId { get; set; } /// /// Gets or sets the identifier of the channel in which the bot keeps a message with the status of the game servers up to date. + /// Without it, the bot uses the status channel of the layout. /// public ulong? StatusChannelId { get; set; } /// /// Gets or sets the identifiers of the channels per , into which the bot posts the game events. + /// They take precedence over the channels of the layout. /// A category with a channel is posted by the bot instead of its webhook. /// public Dictionary Channels { get; set; } = new(); + /// + /// Gets or sets the path of a JSON file with the layout of the Discord server, which the command /openmu setup sets up. + /// Without it, the default layout is used. + /// + public string? LayoutFile { get; set; } + /// /// Gets or sets the interval in which the status message and the presence of the bot are updated. /// diff --git a/src/Discord/DiscordChannelRouting.cs b/src/Discord/DiscordChannelRouting.cs new file mode 100644 index 0000000000..2a2bb71c4c --- /dev/null +++ b/src/Discord/DiscordChannelRouting.cs @@ -0,0 +1,74 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord; + +using MUnique.OpenMU.Discord.Provisioning; + +/// +/// Knows the channels into which the bot posts: the channels which are configured in the , +/// and otherwise the channels of the . +/// +public sealed class DiscordChannelRouting +{ + private readonly IReadOnlyDictionary _notificationChannels; + + private DiscordChannelRouting(IReadOnlyDictionary notificationChannels, ulong? statusChannelId) + { + this._notificationChannels = notificationChannels; + this.StatusChannelId = statusChannelId; + } + + /// + /// Gets the identifier of the channel in which the bot shows the status of the game servers. + /// + public ulong? StatusChannelId { get; } + + /// + /// Creates the routing. + /// + /// The settings of the bot. Its channels take precedence over the ones of the layout. + /// The layout. + /// The identifiers of the existing channels of the layout, by their key. + /// The routing. + public static DiscordChannelRouting Create(DiscordBotSettings settings, DiscordServerLayout layout, IReadOnlyDictionary layoutChannelIds) + { + var notificationChannels = new Dictionary(); + ulong? statusChannelId = null; + foreach (var channel in layout.Channels) + { + if (!layoutChannelIds.TryGetValue(channel.Key, out var channelId)) + { + continue; + } + + foreach (var category in channel.Notifications) + { + notificationChannels.TryAdd(category, channelId); + } + + if (channel.ShowsStatus) + { + statusChannelId ??= channelId; + } + } + + foreach (var (category, channelId) in settings.Channels) + { + notificationChannels[category] = channelId; + } + + return new DiscordChannelRouting(notificationChannels, settings.StatusChannelId ?? statusChannelId); + } + + /// + /// Gets the identifier of the channel into which the notifications of the category are posted. + /// + /// The category. + /// The identifier of the channel; or null, if there is none. + public ulong? GetChannelId(DiscordChannelCategory category) + { + return this._notificationChannels.TryGetValue(category, out var channelId) ? channelId : null; + } +} diff --git a/src/Discord/DiscordCommands.cs b/src/Discord/DiscordCommands.cs index 866d106a45..ee1bd0ae4d 100644 --- a/src/Discord/DiscordCommands.cs +++ b/src/Discord/DiscordCommands.cs @@ -8,6 +8,7 @@ namespace MUnique.OpenMU.Discord; using System.Text; using Microsoft.Extensions.Logging; using MUnique.OpenMU.Discord.Properties; +using MUnique.OpenMU.Discord.Provisioning; using MUnique.OpenMU.Interfaces; /// @@ -28,6 +29,11 @@ public sealed class DiscordCommands /// internal const int RankingSize = 10; + /// + /// The maximum length of the description of an embed, which Discord accepts. + /// + private const int MaximumDescriptionLength = 4096; + private readonly IDiscordGameDataProvider _data; private readonly CultureInfo _culture; private readonly ILogger _logger; @@ -101,6 +107,54 @@ internal string Text(string resourceKey, params object[] args) return args.Length == 0 ? text : string.Format(this._culture, text, args); } + /// + /// Creates the answer to the setup of the Discord server. + /// + /// The result of the setup. + /// The answer. + internal DiscordEmbed CreateSetupReport(DiscordProvisioningResult result) + { + var description = new StringBuilder(); + if (result.Created.Count > 0) + { + description.AppendLine(this.Text(nameof(Resources.Setup_Created), string.Join(", ", result.Created.Select(DiscordMessageFormatter.Escape)))); + } + + if (result.Adopted.Count > 0) + { + description.AppendLine(this.Text(nameof(Resources.Setup_Adopted), string.Join(", ", result.Adopted.Select(DiscordMessageFormatter.Escape)))); + } + + if (result.PublicCategories.Count > 0) + { + description.AppendLine(this.Text(nameof(Resources.Setup_PublicCategories), string.Join(", ", result.PublicCategories.Select(DiscordMessageFormatter.Escape)))); + } + + if (result.Failed.Count > 0) + { + description + .AppendLine(this.Text(nameof(Resources.Setup_Failed), string.Join(", ", result.Failed.Select(failure => $"{DiscordMessageFormatter.Escape(failure.Name)} ({DiscordMessageFormatter.Escape(failure.Reason)})")))) + .AppendLine(this.Text(nameof(Resources.Setup_FailedHint))); + } + + var text = description.ToString().TrimEnd(); + if (text.Length > MaximumDescriptionLength) + { + text = text[..(MaximumDescriptionLength - 1)] + "…"; + } + + return this.Answer(this.Text(nameof(Resources.Setup_Title)), text); + } + + /// + /// Creates the answer to somebody who isn't allowed to set up the Discord server. + /// + /// The answer. + internal DiscordEmbed CreateAdministratorRequiredAnswer() + { + return this.Answer(this.Text(nameof(Resources.Setup_Title)), this.Text(nameof(Resources.Setup_AdministratorRequired))); + } + private DiscordEmbed Online() { var servers = this._data.GetGameServers(); diff --git a/src/Discord/DiscordNotifier.cs b/src/Discord/DiscordNotifier.cs index 2b1bbbdc60..b764ee495c 100644 --- a/src/Discord/DiscordNotifier.cs +++ b/src/Discord/DiscordNotifier.cs @@ -21,7 +21,10 @@ public sealed class DiscordNotifier : IGameEventListener, IAsyncDisposable private readonly DiscordSettings _settings; private readonly DiscordMessageFormatter _formatter; - private readonly IReadOnlyDictionary _senders; + private readonly IReadOnlyDictionary _webhookSenders; + private readonly ConcurrentDictionary _botSenders = new(); + private readonly IDiscordMessenger? _botMessenger; + private readonly ILogger _botSenderLogger; private readonly ILogger _logger; private readonly ConcurrentDictionary<(byte ServerId, string MiniGameType, DateTime EnterEndsAt), DateTime> _announcedOpenings = new(); @@ -48,21 +51,14 @@ public DiscordNotifier( this._logger = loggerFactory.CreateLogger(); this._formatter = new DiscordMessageFormatter(settings.GetCulture(), getServerName, getGuildName); - var senders = new Dictionary(); - if (botMessenger is not null) - { - var botLogger = loggerFactory.CreateLogger(); - foreach (var (category, channelId) in settings.Bot.Channels) - { - senders[category] = new DiscordBotChannelSender(channelId, botMessenger, botLogger, settings.MaximumQueuedMessages); - } - } + this._botMessenger = botMessenger; + this._botSenderLogger = loggerFactory.CreateLogger(); + var senders = new Dictionary(); var senderLogger = loggerFactory.CreateLogger(); foreach (var (category, url) in settings.Webhooks) { - // A category which the bot posts doesn't need the webhook. - if (string.IsNullOrWhiteSpace(url) || senders.ContainsKey(category)) + if (string.IsNullOrWhiteSpace(url)) { continue; } @@ -76,31 +72,45 @@ public DiscordNotifier( senders[category] = new DiscordWebhookSender(uri, httpClient, senderLogger, settings.MaximumQueuedMessages, timeProvider); } - this._senders = senders; + this._webhookSenders = senders; } /// + /// + /// A category which has a channel of the bot is posted by the bot, otherwise by its webhook. + /// public async ValueTask OnGameEventAsync(GameEvent gameEvent) { if (!this.ShouldAnnounce(gameEvent) - || await this._formatter.FormatAsync(gameEvent).ConfigureAwait(false) is not { } message - || !this._senders.TryGetValue(message.Category, out var sender)) + || await this._formatter.FormatAsync(gameEvent).ConfigureAwait(false) is not { } message) { return; } - sender.Enqueue(message.Embed); + if (this._botMessenger?.GetChannelId(message.Category) is { } channelId) + { + this._botSenders.GetOrAdd(channelId, this.CreateBotSender).Enqueue(message.Embed); + } + else if (this._webhookSenders.TryGetValue(message.Category, out var webhookSender)) + { + webhookSender.Enqueue(message.Embed); + } } /// public async ValueTask DisposeAsync() { - foreach (var sender in this._senders.Values) + foreach (var sender in this._webhookSenders.Values.Concat(this._botSenders.Values)) { await sender.DisposeAsync().ConfigureAwait(false); } } + private DiscordBotChannelSender CreateBotSender(ulong channelId) + { + return new DiscordBotChannelSender(channelId, this._botMessenger!, this._botSenderLogger, this._settings.MaximumQueuedMessages); + } + private bool ShouldAnnounce(GameEvent gameEvent) { if (gameEvent is MiniGameEntranceOpenedEvent or MiniGameStartedEvent or MiniGameEndedEvent or InvasionStartedEvent or InvasionEndedEvent diff --git a/src/Discord/DiscordServiceCollectionExtensions.cs b/src/Discord/DiscordServiceCollectionExtensions.cs index b13dd8991b..335c15eabb 100644 --- a/src/Discord/DiscordServiceCollectionExtensions.cs +++ b/src/Discord/DiscordServiceCollectionExtensions.cs @@ -7,6 +7,7 @@ namespace MUnique.OpenMU.Discord; using System.Net.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using MUnique.OpenMU.Discord.Provisioning; using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.Persistence; using MUnique.OpenMU.PlugIns; @@ -22,7 +23,7 @@ public static class DiscordServiceCollectionExtensions { /// /// Adds the Discord bot, if a token is configured, and the as , - /// if webhooks or channels of the bot are configured. + /// if webhooks or the bot are configured. /// /// The services to which the Discord integration is added. /// The settings. @@ -43,11 +44,15 @@ public static IServiceCollection AddDiscord(this IServiceCollection services, Di getServerTimeZone)) .AddSingleton(provider => new DiscordCommands(provider.GetRequiredService(), culture, provider.GetRequiredService>())) .AddSingleton(new DiscordStatusFormatter(culture)) + .AddSingleton(settings.Bot.LayoutFile is { Length: > 0 } layoutFile ? DiscordServerLayout.Load(layoutFile) : DiscordServerLayout.LoadDefault()) + .AddSingleton(provider => new DiscordServerProvisioner(provider.GetRequiredService>())) .AddSingleton(provider => new DiscordBot( settings.Bot, provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService(), provider.GetRequiredService>())) .AddHostedService(provider => provider.GetRequiredService()); } diff --git a/src/Discord/DiscordSettings.cs b/src/Discord/DiscordSettings.cs index 22aea23967..31e071588b 100644 --- a/src/Discord/DiscordSettings.cs +++ b/src/Discord/DiscordSettings.cs @@ -22,7 +22,7 @@ public class DiscordSettings /// /// Gets or sets the webhook URLs per . - /// A category without URL isn't posted, unless the bot posts it, see . + /// A category without URL isn't posted, unless the bot posts it into a channel of the layout or of . /// public Dictionary Webhooks { get; set; } = new(); @@ -52,8 +52,7 @@ public class DiscordSettings /// /// Gets a value indicating whether game events are posted, through webhooks or the bot. /// - public bool IsNotificationEnabled => this.Webhooks.Values.Any(url => !string.IsNullOrWhiteSpace(url)) - || (this.Bot.IsEnabled && this.Bot.Channels.Count > 0); + public bool IsNotificationEnabled => this.Bot.IsEnabled || this.Webhooks.Values.Any(url => !string.IsNullOrWhiteSpace(url)); /// /// Gets the culture of the ; or the invariant culture, if the language is unknown. diff --git a/src/Discord/IDiscordMessenger.cs b/src/Discord/IDiscordMessenger.cs index 27bccefa76..091331fea8 100644 --- a/src/Discord/IDiscordMessenger.cs +++ b/src/Discord/IDiscordMessenger.cs @@ -11,6 +11,13 @@ namespace MUnique.OpenMU.Discord; /// public interface IDiscordMessenger { + /// + /// Gets the identifier of the channel into which the notifications of the category are posted. + /// + /// The category. + /// The identifier of the channel; or null, if the messenger doesn't post the category. + ulong? GetChannelId(DiscordChannelCategory category); + /// /// Sends a message with the embeds to the channel. It waits until the messenger is connected. /// Mentions in the message don't ping anybody. diff --git a/src/Discord/MUnique.OpenMU.Discord.csproj b/src/Discord/MUnique.OpenMU.Discord.csproj index f5a84486f4..a416512b06 100644 --- a/src/Discord/MUnique.OpenMU.Discord.csproj +++ b/src/Discord/MUnique.OpenMU.Discord.csproj @@ -28,6 +28,10 @@ + + + + True diff --git a/src/Discord/Properties/Resources.Designer.cs b/src/Discord/Properties/Resources.Designer.cs index aebb685f21..17b0340286 100644 --- a/src/Discord/Properties/Resources.Designer.cs +++ b/src/Discord/Properties/Resources.Designer.cs @@ -231,6 +231,15 @@ public static string Command_Online_Description { } } + /// + /// Looks up a localized string similar to Administrates the OpenMU integration of this Discord server.. + /// + public static string Command_OpenMu_Description { + get { + return ResourceManager.GetString("Command_OpenMu_Description", resourceCulture); + } + } + /// /// Looks up a localized string similar to Shows the best characters.. /// @@ -240,6 +249,15 @@ public static string Command_Rank_Description { } } + /// + /// Looks up a localized string similar to Creates the missing channels and roles of this Discord server.. + /// + public static string Command_Setup_Description { + get { + return ResourceManager.GetString("Command_Setup_Description", resourceCulture); + } + } + /// /// Looks up a localized string similar to The name of the character.. /// @@ -501,6 +519,69 @@ public static string ServerFooter { } } + /// + /// Looks up a localized string similar to Only administrators can set up this Discord server.. + /// + public static string Setup_AdministratorRequired { + get { + return ResourceManager.GetString("Setup_AdministratorRequired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to **Already existing:** {0}. + /// + public static string Setup_Adopted { + get { + return ResourceManager.GetString("Setup_Adopted", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to **Created:** {0}. + /// + public static string Setup_Created { + get { + return ResourceManager.GetString("Setup_Created", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to **Failed:** {0}. + /// + public static string Setup_Failed { + get { + return ResourceManager.GetString("Setup_Failed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Please check that the bot has the permissions "Manage Channels" and "Manage Roles", and that its role is above the roles which it should manage.. + /// + public static string Setup_FailedHint { + get { + return ResourceManager.GetString("Setup_FailedHint", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to **Visible to everyone:** {0}, because their roles couldn't be created.. + /// + public static string Setup_PublicCategories { + get { + return ResourceManager.GetString("Setup_PublicCategories", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Setup of the Discord server. + /// + public static string Setup_Title { + get { + return ResourceManager.GetString("Setup_Title", resourceCulture); + } + } + /// /// Looks up a localized string similar to No game server is running.. /// diff --git a/src/Discord/Properties/Resources.de.resx b/src/Discord/Properties/Resources.de.resx index f6c25f1dce..dbf4700d2a 100644 --- a/src/Discord/Properties/Resources.de.resx +++ b/src/Discord/Properties/Resources.de.resx @@ -155,9 +155,15 @@ Zeigt, wie viele Spieler online sind. + + Verwaltet die OpenMU-Integration dieses Discord-Servers. + Zeigt die besten Charaktere. + + Erstellt die fehlenden Kanäle und Rollen dieses Discord-Servers. + Der Name des Charakters. @@ -245,6 +251,27 @@ Server: {0} + + Nur Administratoren können diesen Discord-Server einrichten. + + + **Bereits vorhanden:** {0} + + + **Erstellt:** {0} + + + **Fehlgeschlagen:** {0} + + + Bitte prüfe, ob der Bot die Berechtigungen "Kanäle verwalten" und "Rollen verwalten" hat, und ob seine Rolle über den Rollen steht, die er verwalten soll. + + + **Für alle sichtbar:** {0}, weil ihre Rollen nicht erstellt werden konnten. + + + Einrichtung des Discord-Servers + Es läuft kein Spielserver. @@ -257,4 +284,5 @@ Serverstatus + Private Kanäle wurden übersprungen, weil die erforderlichen Rollen fehlen. diff --git a/src/Discord/Properties/Resources.resx b/src/Discord/Properties/Resources.resx index b5389fc4be..b8af49c675 100644 --- a/src/Discord/Properties/Resources.resx +++ b/src/Discord/Properties/Resources.resx @@ -155,9 +155,15 @@ Shows how many players are online. + + Administrates the OpenMU integration of this Discord server. + Shows the best characters. + + Creates the missing channels and roles of this Discord server. + The name of the character. @@ -245,6 +251,27 @@ Server: {0} + + Only administrators can set up this Discord server. + + + **Already existing:** {0} + + + **Created:** {0} + + + **Failed:** {0} + + + Please check that the bot has the permissions "Manage Channels" and "Manage Roles", and that its role is above the roles which it should manage. + + + **Visible to everyone:** {0}, because their roles couldn't be created. + + + Setup of the Discord server + No game server is running. @@ -257,4 +284,5 @@ Server status + Private channels were skipped because their required roles are missing. diff --git a/src/Discord/Provisioning/DefaultLayout.json b/src/Discord/Provisioning/DefaultLayout.json new file mode 100644 index 0000000000..2d337d77d5 --- /dev/null +++ b/src/Discord/Provisioning/DefaultLayout.json @@ -0,0 +1,37 @@ +{ + // The default layout of the Discord server of an OpenMU game server. + // Existing roles, categories and channels are adopted by their name; the missing ones are created. + "roles": [ + { "key": "gm", "name": "GM", "color": "#E67E22", "isHoisted": true }, + { "key": "linked", "name": "Linked Player", "color": "#2ECC71" } + ], + "categories": [ + { + "key": "info", + "name": "Info", + "channels": [ + { "key": "announcements", "name": "announcements", "topic": "News from the game masters.", "isAnnouncement": true, "isReadOnly": true, "notifications": [ "Notices" ] }, + { "key": "events", "name": "events", "topic": "Mini games, invasions and the castle siege.", "isReadOnly": true, "notifications": [ "Events", "CastleSiege" ] }, + { "key": "server-status", "name": "server-status", "topic": "The status of the game servers.", "isReadOnly": true, "showsStatus": true } + ] + }, + { + "key": "community", + "name": "Community", + "channels": [ + { "key": "world-news", "name": "world-news", "topic": "Boss kills, rare drops and level milestones.", "isReadOnly": true, "notifications": [ "WorldNews" ] }, + { "key": "world-chat", "name": "world-chat" }, + { "key": "general", "name": "general" } + ] + }, + { + "key": "staff", + "name": "Staff", + "visibleTo": [ "gm" ], + "channels": [ + { "key": "staff-alerts", "name": "staff-alerts", "isReadOnly": true }, + { "key": "gm-commands", "name": "gm-commands" } + ] + } + ] +} diff --git a/src/Discord/Provisioning/DiscordCategoryLayout.cs b/src/Discord/Provisioning/DiscordCategoryLayout.cs new file mode 100644 index 0000000000..92384265e6 --- /dev/null +++ b/src/Discord/Provisioning/DiscordCategoryLayout.cs @@ -0,0 +1,32 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.Provisioning; + +/// +/// A category of channels of the . +/// +public sealed class DiscordCategoryLayout +{ + /// + /// Gets or sets the key. + /// + public string Key { get; set; } = string.Empty; + + /// + /// Gets or sets the name. An existing category with this name is adopted. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the keys of the roles which can see the category. + /// If it's empty, everybody can see it. + /// + public List VisibleTo { get; set; } = new(); + + /// + /// Gets or sets the channels. + /// + public List Channels { get; set; } = new(); +} diff --git a/src/Discord/Provisioning/DiscordChannelAccess.cs b/src/Discord/Provisioning/DiscordChannelAccess.cs new file mode 100644 index 0000000000..151d074fa6 --- /dev/null +++ b/src/Discord/Provisioning/DiscordChannelAccess.cs @@ -0,0 +1,27 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.Provisioning; + +/// +/// The access to a channel, which the provisioning sets. +/// +[Flags] +public enum DiscordChannelAccess +{ + /// + /// No access. + /// + None = 0, + + /// + /// The channel can be seen and its messages can be read. + /// + View = 1, + + /// + /// Messages can be sent into the channel. + /// + Send = 2, +} diff --git a/src/Discord/Provisioning/DiscordChannelInfo.cs b/src/Discord/Provisioning/DiscordChannelInfo.cs new file mode 100644 index 0000000000..781f1128c4 --- /dev/null +++ b/src/Discord/Provisioning/DiscordChannelInfo.cs @@ -0,0 +1,15 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.Provisioning; + +/// +/// An existing channel or category of a Discord server. +/// +/// The identifier. +/// The name. +/// A value indicating whether it's a category. +/// The identifier of the category of the channel, if it has one. +/// A value indicating whether the everyone role is denied access. +public sealed record DiscordChannelInfo(ulong Id, string Name, bool IsCategory, ulong? CategoryId, bool IsPrivate = false); diff --git a/src/Discord/Provisioning/DiscordChannelLayout.cs b/src/Discord/Provisioning/DiscordChannelLayout.cs new file mode 100644 index 0000000000..e650bd11ae --- /dev/null +++ b/src/Discord/Provisioning/DiscordChannelLayout.cs @@ -0,0 +1,47 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.Provisioning; + +/// +/// A text channel of the . +/// +public sealed class DiscordChannelLayout +{ + /// + /// Gets or sets the key, which stays the same when the name changes. + /// + public string Key { get; set; } = string.Empty; + + /// + /// Gets or sets the name, e.g. events. An existing channel with this name is adopted. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the topic. + /// + public string? Topic { get; set; } + + /// + /// Gets or sets a value indicating whether the channel is an announcement channel, which other Discord servers can follow. + /// It requires a community server; otherwise, a normal text channel is created. + /// + public bool IsAnnouncement { get; set; } + + /// + /// Gets or sets a value indicating whether only the bot can write into the channel. + /// + public bool IsReadOnly { get; set; } + + /// + /// Gets or sets the categories of notifications which are posted into the channel. + /// + public List Notifications { get; set; } = new(); + + /// + /// Gets or sets a value indicating whether the bot shows the status of the game servers in the channel. + /// + public bool ShowsStatus { get; set; } +} diff --git a/src/Discord/Provisioning/DiscordPermissionOverwrite.cs b/src/Discord/Provisioning/DiscordPermissionOverwrite.cs new file mode 100644 index 0000000000..cc5b35159b --- /dev/null +++ b/src/Discord/Provisioning/DiscordPermissionOverwrite.cs @@ -0,0 +1,15 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.Provisioning; + +/// +/// An overwrite of the permissions of a channel, for a role or a user. +/// The access which is neither allowed nor denied stays as it is. +/// +/// The identifier of the role or user. +/// A value indicating whether the target is a role; otherwise, it's a user. +/// The allowed access. +/// The denied access. +public sealed record DiscordPermissionOverwrite(ulong TargetId, bool IsRole, DiscordChannelAccess Allow, DiscordChannelAccess Deny); diff --git a/src/Discord/Provisioning/DiscordProvisioningResult.cs b/src/Discord/Provisioning/DiscordProvisioningResult.cs new file mode 100644 index 0000000000..09301a5d3e --- /dev/null +++ b/src/Discord/Provisioning/DiscordProvisioningResult.cs @@ -0,0 +1,37 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.Provisioning; + +/// +/// The result of the . +/// +public sealed class DiscordProvisioningResult +{ + /// + /// Gets the names of the created roles, categories and channels. + /// + public List Created { get; } = new(); + + /// + /// Gets the names of the roles, categories and channels which already existed. + /// + public List Adopted { get; } = new(); + + /// + /// Gets the names of the roles, categories and channels which couldn't be created or set up, with the reason. + /// + public List<(string Name, string Reason)> Failed { get; } = new(); + + /// + /// Gets the names of the categories which should only be visible to some roles, but stay visible to everyone, + /// because none of their roles exist. + /// + public List PublicCategories { get; } = new(); + + /// + /// Gets the identifiers of the channels by their key. + /// + public Dictionary ChannelIds { get; } = new(StringComparer.OrdinalIgnoreCase); +} diff --git a/src/Discord/Provisioning/DiscordRoleInfo.cs b/src/Discord/Provisioning/DiscordRoleInfo.cs new file mode 100644 index 0000000000..4ca60a79e9 --- /dev/null +++ b/src/Discord/Provisioning/DiscordRoleInfo.cs @@ -0,0 +1,12 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.Provisioning; + +/// +/// An existing role of a Discord server. +/// +/// The identifier. +/// The name. +public sealed record DiscordRoleInfo(ulong Id, string Name); diff --git a/src/Discord/Provisioning/DiscordRoleLayout.cs b/src/Discord/Provisioning/DiscordRoleLayout.cs new file mode 100644 index 0000000000..87584294b2 --- /dev/null +++ b/src/Discord/Provisioning/DiscordRoleLayout.cs @@ -0,0 +1,31 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.Provisioning; + +/// +/// A role of the . +/// +public sealed class DiscordRoleLayout +{ + /// + /// Gets or sets the key, by which the categories refer to the role. + /// + public string Key { get; set; } = string.Empty; + + /// + /// Gets or sets the name. An existing role with this name is adopted. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the color as hexadecimal RGB value, e.g. #E67E22. + /// + public string? Color { get; set; } + + /// + /// Gets or sets a value indicating whether the members of the role are shown separately in the member list. + /// + public bool IsHoisted { get; set; } +} diff --git a/src/Discord/Provisioning/DiscordServerLayout.cs b/src/Discord/Provisioning/DiscordServerLayout.cs new file mode 100644 index 0000000000..ac0ae3af51 --- /dev/null +++ b/src/Discord/Provisioning/DiscordServerLayout.cs @@ -0,0 +1,146 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.Provisioning; + +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +/// +/// The layout of a Discord server for a game server: its roles, categories and channels. +/// The channels are identified by their keys, so that the notifications find their channels +/// without configuring the identifiers of the channels. +/// +public sealed class DiscordServerLayout +{ + private const string DefaultLayoutResourceName = "MUnique.OpenMU.Discord.Provisioning.DefaultLayout.json"; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + Converters = { new JsonStringEnumConverter() }, + }; + + /// + /// Gets or sets the roles. + /// + public List Roles { get; set; } = new(); + + /// + /// Gets or sets the categories with their channels. + /// + public List Categories { get; set; } = new(); + + /// + /// Gets all channels. + /// + [JsonIgnore] + public IEnumerable Channels => this.Categories.SelectMany(category => category.Channels); + + /// + /// Loads the default layout, which is part of this assembly. + /// + /// The default layout. + public static DiscordServerLayout LoadDefault() + { + using var stream = typeof(DiscordServerLayout).Assembly.GetManifestResourceStream(DefaultLayoutResourceName) + ?? throw new InvalidOperationException($"The resource {DefaultLayoutResourceName} wasn't found."); + return Load(stream); + } + + /// + /// Loads a layout from a JSON file. + /// + /// The path of the file. + /// The layout. + public static DiscordServerLayout Load(string path) + { + using var stream = File.OpenRead(path); + return Load(stream); + } + + /// + /// Loads a layout from a JSON stream. + /// + /// The stream. + /// The layout. + /// If the layout isn't valid. + public static DiscordServerLayout Load(Stream stream) + { + var layout = JsonSerializer.Deserialize(stream, SerializerOptions) + ?? throw new InvalidDataException("The Discord server layout is empty."); + var errors = layout.Validate(); + if (errors.Count > 0) + { + throw new InvalidDataException("The Discord server layout is invalid: " + string.Join(" ", errors)); + } + + return layout; + } + + /// + /// Validates the layout. + /// + /// The errors; empty, if the layout is valid. + public IReadOnlyList Validate() + { + // The keys identify the items in the layout, and the names in the Discord server, where existing items are adopted by their name. + var errors = new List(); + var roles = new ItemNames(); + foreach (var role in this.Roles) + { + ValidateItem(errors, "role", role.Key, role.Name, roles); + } + + var categories = new ItemNames(); + var channels = new ItemNames(); + foreach (var category in this.Categories) + { + ValidateItem(errors, "category", category.Key, category.Name, categories); + foreach (var roleKey in category.VisibleTo.Where(roleKey => !roles.Keys.Contains(roleKey))) + { + errors.Add($"The category '{category.Key}' refers to the unknown role '{roleKey}'."); + } + + foreach (var channel in category.Channels) + { + ValidateItem(errors, "channel", channel.Key, channel.Name, channels); + } + } + + return errors; + } + + private static void ValidateItem(List errors, string kind, string key, string name, ItemNames names) + { + if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(name)) + { + errors.Add($"Every {kind} needs a key and a name."); + return; + } + + if (!names.Keys.Add(key)) + { + errors.Add($"The {kind} key '{key}' isn't unique."); + } + + if (!names.Names.Add(name)) + { + errors.Add($"The {kind} name '{name}' of '{key}' isn't unique."); + } + } + + /// + /// The keys and names of the items of one kind, which have to be unique. + /// + private sealed class ItemNames + { + public HashSet Keys { get; } = new(StringComparer.OrdinalIgnoreCase); + + public HashSet Names { get; } = new(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/Discord/Provisioning/DiscordServerProvisioner.cs b/src/Discord/Provisioning/DiscordServerProvisioner.cs new file mode 100644 index 0000000000..215e3cd5db --- /dev/null +++ b/src/Discord/Provisioning/DiscordServerProvisioner.cs @@ -0,0 +1,239 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.Provisioning; + +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +/// +/// Sets up a Discord server according to a . +/// +/// +/// It can run any number of times: Existing roles, categories and channels are adopted by their name, +/// and only the missing ones are created. Nothing is deleted or renamed, and permissions which +/// the layout doesn't define stay as they are. +/// +public sealed class DiscordServerProvisioner +{ + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + public DiscordServerProvisioner(ILogger logger) + { + this._logger = logger; + } + + /// + /// Finds the channels of the layout in the existing channels, by their name. + /// + /// The layout. + /// The existing channels and categories. + /// The identifiers of the found channels by their key. + public static IReadOnlyDictionary FindChannels(DiscordServerLayout layout, IReadOnlyCollection channels) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var category in layout.Categories) + { + var categoryId = FindCategory(channels, category.Name)?.Id; + foreach (var channel in category.Channels) + { + if (FindChannel(channels, channel.Name, categoryId) is { } existing + && (category.VisibleTo.Count == 0 || existing.IsPrivate)) + { + result[channel.Key] = existing.Id; + } + } + } + + return result; + } + + /// + /// Sets up the Discord server according to the layout. + /// + /// The Discord server. + /// The layout. + /// The result. + public async Task ProvisionAsync(IDiscordServer server, DiscordServerLayout layout) + { + var result = new DiscordProvisioningResult(); + var roleIds = await this.ProvisionRolesAsync(server, layout, result).ConfigureAwait(false); + + var channels = server.GetChannels(); + foreach (var category in layout.Categories) + { + if (category.VisibleTo.Count > 0 && category.VisibleTo.Any(key => !roleIds.ContainsKey(key))) + { + // Do not create or adopt staff channels until every required role exists. + result.Failed.Add((category.Name, Properties.Resources.ResourceManager.GetString("Setup_PrivateRolesMissing") ?? string.Empty)); + continue; + } + + var categoryId = await this.GetOrCreateAsync( + result, + category.Name, + () => FindCategory(channels, category.Name)?.Id, + () => server.CreateCategoryAsync(category)).ConfigureAwait(false); + if (categoryId is null) + { + continue; + } + + if (!await this.ApplyPermissionsAsync(server, result, category.Name, categoryId.Value, CreateOverwrites(server, roleIds, category, null)).ConfigureAwait(false)) + { + continue; + } + + await this.ProvisionChannelsAsync(server, channels, roleIds, category, categoryId.Value, result).ConfigureAwait(false); + } + + return result; + } + + /// + /// Creates the overwrites of the permissions of a category or a channel. + /// + /// The Discord server. + /// The identifiers of the roles by their key. + /// The category. + /// The channel; or null, for the category itself. + /// The overwrites. + internal static IReadOnlyList CreateOverwrites(IDiscordServer server, IReadOnlyDictionary roleIds, DiscordCategoryLayout category, DiscordChannelLayout? channel) + { + // Privacy is a property of the layout, never of whether role creation happened to succeed. + var isPrivate = category.VisibleTo.Count > 0; + var isReadOnly = channel?.IsReadOnly ?? false; + if (!isPrivate && !isReadOnly) + { + return []; + } + + var everyoneDeny = (isPrivate ? DiscordChannelAccess.View : DiscordChannelAccess.None) + | (isReadOnly ? DiscordChannelAccess.Send : DiscordChannelAccess.None); + var overwrites = new List + { + // The bot always needs to see the channels and to post into them. Its overwrite comes first: the overwrites + // are set one after another, and once @everyone is denied to view a channel, the bot couldn't set the others. + new(server.BotUserId, false, DiscordChannelAccess.View | DiscordChannelAccess.Send, DiscordChannelAccess.None), + new(server.EveryoneRoleId, true, DiscordChannelAccess.None, everyoneDeny), + }; + + if (isPrivate) + { + var roleAccess = isReadOnly ? DiscordChannelAccess.View : DiscordChannelAccess.View | DiscordChannelAccess.Send; + overwrites.AddRange(category.VisibleTo + .Where(roleIds.ContainsKey) + .Select(roleKey => new DiscordPermissionOverwrite(roleIds[roleKey], true, roleAccess, DiscordChannelAccess.None))); + } + + return overwrites; + } + + private static DiscordChannelInfo? FindCategory(IReadOnlyCollection channels, string name) + { + return channels.FirstOrDefault(c => c.IsCategory && string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Finds a channel by its name. A channel in the category is preferred, but it may also have been moved somewhere else. + /// + private static DiscordChannelInfo? FindChannel(IReadOnlyCollection channels, string name, ulong? categoryId) + { + var candidates = channels.Where(c => !c.IsCategory && string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase)).ToList(); + return candidates.FirstOrDefault(c => c.CategoryId == categoryId) ?? candidates.FirstOrDefault(); + } + + private async Task ProvisionChannelsAsync( + IDiscordServer server, + IReadOnlyCollection channels, + IReadOnlyDictionary roleIds, + DiscordCategoryLayout category, + ulong categoryId, + DiscordProvisioningResult result) + { + foreach (var channel in category.Channels) + { + var channelId = await this.GetOrCreateAsync( + result, + "#" + channel.Name, + () => FindChannel(channels, channel.Name, categoryId)?.Id, + () => server.CreateChannelAsync(channel, categoryId)).ConfigureAwait(false); + if (channelId is null) + { + continue; + } + + if (await this.ApplyPermissionsAsync(server, result, "#" + channel.Name, channelId.Value, CreateOverwrites(server, roleIds, category, channel)).ConfigureAwait(false)) + { + result.ChannelIds[channel.Key] = channelId.Value; + } + } + } + + private async Task> ProvisionRolesAsync(IDiscordServer server, DiscordServerLayout layout, DiscordProvisioningResult result) + { + var roles = server.GetRoles(); + var roleIds = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var role in layout.Roles) + { + var roleId = await this.GetOrCreateAsync( + result, + "@" + role.Name, + () => roles.FirstOrDefault(r => string.Equals(r.Name, role.Name, StringComparison.OrdinalIgnoreCase))?.Id, + () => server.CreateRoleAsync(role)).ConfigureAwait(false); + if (roleId is { } id) + { + roleIds[role.Key] = id; + } + } + + return roleIds; + } + + private async Task GetOrCreateAsync(DiscordProvisioningResult result, string displayName, Func find, Func> create) + { + if (find() is { } existingId) + { + result.Adopted.Add(displayName); + return existingId; + } + + try + { + var id = await create().ConfigureAwait(false); + result.Created.Add(displayName); + return id; + } + catch (Exception ex) + { + this._logger.LogWarning(ex, "{name} couldn't be created on the Discord server.", displayName); + result.Failed.Add((displayName, ex.Message)); + return null; + } + } + + private async Task ApplyPermissionsAsync(IDiscordServer server, DiscordProvisioningResult result, string displayName, ulong channelId, IReadOnlyList overwrites) + { + if (overwrites.Count == 0) + { + return true; + } + + try + { + await server.ApplyPermissionsAsync(channelId, overwrites).ConfigureAwait(false); + return true; + } + catch (Exception ex) + { + this._logger.LogWarning(ex, "The permissions of {name} couldn't be set on the Discord server.", displayName); + result.Failed.Add((displayName, ex.Message)); + return false; + } + } +} diff --git a/src/Discord/Provisioning/IDiscordServer.cs b/src/Discord/Provisioning/IDiscordServer.cs new file mode 100644 index 0000000000..75b7db9cb4 --- /dev/null +++ b/src/Discord/Provisioning/IDiscordServer.cs @@ -0,0 +1,63 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.Provisioning; + +/// +/// A Discord server (guild), as far as the needs it. +/// +public interface IDiscordServer +{ + /// + /// Gets the identifier of the role which everybody has. + /// + ulong EveryoneRoleId { get; } + + /// + /// Gets the user identifier of the bot. + /// + ulong BotUserId { get; } + + /// + /// Gets the roles. + /// + /// The roles. + IReadOnlyCollection GetRoles(); + + /// + /// Gets the channels and categories. + /// + /// The channels and categories. + IReadOnlyCollection GetChannels(); + + /// + /// Creates a role. + /// + /// The role. + /// The identifier of the created role. + Task CreateRoleAsync(DiscordRoleLayout role); + + /// + /// Creates a category. + /// + /// The category. + /// The identifier of the created category. + Task CreateCategoryAsync(DiscordCategoryLayout category); + + /// + /// Creates a text channel. + /// + /// The channel. + /// The identifier of the category of the channel. + /// The identifier of the created channel. + Task CreateChannelAsync(DiscordChannelLayout channel, ulong? categoryId); + + /// + /// Applies the overwrites to the permissions of a channel or category. + /// + /// The identifier of the channel or category. + /// The overwrites. + /// The task. + Task ApplyPermissionsAsync(ulong channelId, IReadOnlyList overwrites); +} diff --git a/src/Discord/Provisioning/SocketGuildServer.cs b/src/Discord/Provisioning/SocketGuildServer.cs new file mode 100644 index 0000000000..f320ceb26f --- /dev/null +++ b/src/Discord/Provisioning/SocketGuildServer.cs @@ -0,0 +1,148 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.Provisioning; + +using System.Globalization; +using global::Discord; +using global::Discord.WebSocket; + +/// +/// A of the Discord library. +/// +/// +/// The cache of the guild learns about created roles and channels a bit later through the gateway, +/// so they are remembered here, until then. +/// +internal sealed class SocketGuildServer : IDiscordServer +{ + private readonly SocketGuild _guild; + private readonly Dictionary _createdRoles = new(); + private readonly Dictionary _createdChannels = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The guild. + public SocketGuildServer(SocketGuild guild) + { + this._guild = guild; + } + + /// + public ulong EveryoneRoleId => this._guild.EveryoneRole.Id; + + /// + public ulong BotUserId => this._guild.CurrentUser.Id; + + /// + /// Gets the channels and categories of a guild. + /// + /// The guild. + /// The channels and categories. + public static IReadOnlyCollection GetChannels(SocketGuild guild) + { + return guild.Channels + .Where(channel => channel is SocketCategoryChannel or SocketTextChannel) + .Select(channel => new DiscordChannelInfo( + channel.Id, + channel.Name, + channel is SocketCategoryChannel, + (channel as INestedChannel)?.CategoryId, + channel.GetPermissionOverwrite(guild.EveryoneRole)?.ViewChannel == PermValue.Deny)) + .ToList(); + } + + /// + public IReadOnlyCollection GetRoles() + { + return this._guild.Roles.Select(role => new DiscordRoleInfo(role.Id, role.Name)).ToList(); + } + + /// + public IReadOnlyCollection GetChannels() => GetChannels(this._guild); + + /// + public async Task CreateRoleAsync(DiscordRoleLayout role) + { + RoleColors? colors = null; + if (role.Color is { } colorText + && uint.TryParse(colorText.TrimStart('#'), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var color)) + { + colors = RoleColors.Solid(new Color(color)); + } + + var createdRole = await this._guild.CreateRoleAsync(role.Name, GuildPermissions.None, colors, role.IsHoisted, false).ConfigureAwait(false); + this._createdRoles[createdRole.Id] = createdRole; + return createdRole.Id; + } + + /// + public async Task CreateCategoryAsync(DiscordCategoryLayout category) + { + var createdCategory = await this._guild.CreateCategoryChannelAsync(category.Name).ConfigureAwait(false); + this._createdChannels[createdCategory.Id] = createdCategory; + return createdCategory.Id; + } + + /// + public async Task CreateChannelAsync(DiscordChannelLayout channel, ulong? categoryId) + { + void SetProperties(TextChannelProperties properties) + { + properties.CategoryId = categoryId; + if (channel.Topic is { } topic) + { + properties.Topic = topic; + } + } + + // Announcement channels are only available on community servers. + ITextChannel createdChannel = channel.IsAnnouncement && this._guild.Features.HasFeature(GuildFeature.Community) + ? await this._guild.CreateNewsChannelAsync(channel.Name, SetProperties).ConfigureAwait(false) + : await this._guild.CreateTextChannelAsync(channel.Name, SetProperties).ConfigureAwait(false); + this._createdChannels[createdChannel.Id] = createdChannel; + return createdChannel.Id; + } + + /// + public async Task ApplyPermissionsAsync(ulong channelId, IReadOnlyList overwrites) + { + if ((this._guild.GetChannel(channelId) as IGuildChannel ?? this._createdChannels.GetValueOrDefault(channelId)) is not { } channel) + { + throw new InvalidOperationException($"The channel {channelId} wasn't found."); + } + + foreach (var overwrite in overwrites) + { + if (overwrite.IsRole) + { + var role = (this._guild.GetRole(overwrite.TargetId) as IRole ?? this._createdRoles.GetValueOrDefault(overwrite.TargetId)) ?? throw new InvalidOperationException($"The role {overwrite.TargetId} wasn't found."); + var permissions = Merge(channel.GetPermissionOverwrite(role), overwrite); + await channel.AddPermissionOverwriteAsync(role, permissions).ConfigureAwait(false); + } + else + { + var user = this._guild.GetUser(overwrite.TargetId) ?? throw new InvalidOperationException($"The user {overwrite.TargetId} wasn't found."); + var permissions = Merge(channel.GetPermissionOverwrite(user), overwrite); + await channel.AddPermissionOverwriteAsync(user, permissions).ConfigureAwait(false); + } + } + } + + /// + /// Merges the overwrite into the existing permissions, so that the permissions which the overwrite doesn't define stay as they are. + /// + private static OverwritePermissions Merge(OverwritePermissions? existing, DiscordPermissionOverwrite overwrite) + { + PermValue? GetValue(DiscordChannelAccess access) => + overwrite.Allow.HasFlag(access) ? PermValue.Allow + : overwrite.Deny.HasFlag(access) ? PermValue.Deny + : null; + + return (existing ?? OverwritePermissions.InheritAll).Modify( + viewChannel: GetValue(DiscordChannelAccess.View), + sendMessages: GetValue(DiscordChannelAccess.Send)); + } +} diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordChannelRoutingTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordChannelRoutingTest.cs new file mode 100644 index 0000000000..309772582b --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordChannelRoutingTest.cs @@ -0,0 +1,48 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Discord; + +using MUnique.OpenMU.Discord; +using MUnique.OpenMU.Discord.Provisioning; + +/// +/// Tests for the . +/// +[TestFixture] +public class DiscordChannelRoutingTest +{ + /// + /// Tests that the notifications and the status are routed to the existing channels of the layout. + /// + [Test] + public void LayoutChannelsAreUsed() + { + var channelIds = new Dictionary { { "events", 10 }, { "server-status", 20 } }; + + var routing = DiscordChannelRouting.Create(new DiscordBotSettings(), DiscordServerLayout.LoadDefault(), channelIds); + + Assert.That(routing.GetChannelId(DiscordChannelCategory.Events), Is.EqualTo(10)); + Assert.That(routing.GetChannelId(DiscordChannelCategory.CastleSiege), Is.EqualTo(10)); + Assert.That(routing.GetChannelId(DiscordChannelCategory.WorldNews), Is.Null); + Assert.That(routing.StatusChannelId, Is.EqualTo(20)); + } + + /// + /// Tests that the configured channels take precedence over the channels of the layout. + /// + [Test] + public void ConfiguredChannelsTakePrecedence() + { + var settings = new DiscordBotSettings { StatusChannelId = 2 }; + settings.Channels[DiscordChannelCategory.Events] = 1; + var channelIds = new Dictionary { { "events", 10 }, { "server-status", 20 } }; + + var routing = DiscordChannelRouting.Create(settings, DiscordServerLayout.LoadDefault(), channelIds); + + Assert.That(routing.GetChannelId(DiscordChannelCategory.Events), Is.EqualTo(1)); + Assert.That(routing.GetChannelId(DiscordChannelCategory.CastleSiege), Is.EqualTo(10)); + Assert.That(routing.StatusChannelId, Is.EqualTo(2)); + } +} diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordCommandsTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordCommandsTest.cs index 60a58cd307..e3de3736d4 100644 --- a/tests/MUnique.OpenMU.Tests/Discord/DiscordCommandsTest.cs +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordCommandsTest.cs @@ -7,6 +7,7 @@ namespace MUnique.OpenMU.Tests.Discord; using System.Globalization; using Microsoft.Extensions.Logging.Abstractions; using MUnique.OpenMU.Discord; +using MUnique.OpenMU.Discord.Provisioning; /// /// Tests for the . @@ -130,6 +131,24 @@ public void AllCommandDescriptionsExist() } } + /// + /// Tests the report of the setup of the Discord server. + /// + [Test] + public void SetupReportListsCreatedAdoptedAndFailed() + { + var result = new DiscordProvisioningResult(); + result.Created.Add("#events"); + result.Created.Add("@GM"); + result.Adopted.Add("#general"); + result.Failed.Add(("Staff", "Missing Permissions")); + + var answer = CreateCommands("en", this._data).CreateSetupReport(result); + + Assert.That(answer.Title, Is.EqualTo("Setup of the Discord server")); + Assert.That(answer.Description, Does.StartWith("**Created:** \\#events, @GM\n**Already existing:** \\#general\n**Failed:** Staff (Missing Permissions)\n")); + } + private static DiscordCommands CreateCommands(string language, IDiscordGameDataProvider data) => new(data, CultureInfo.GetCultureInfo(language), NullLogger.Instance); } diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordNotifierTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordNotifierTest.cs index 912315fc9c..2722ce11fc 100644 --- a/tests/MUnique.OpenMU.Tests/Discord/DiscordNotifierTest.cs +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordNotifierTest.cs @@ -80,8 +80,7 @@ public async Task BotChannelIsPreferredToWebhookAsync() var handler = new RecordingHttpMessageHandler(); var messenger = new RecordingMessenger(); var settings = new DiscordSettings(); - settings.Bot.Token = "token"; - settings.Bot.Channels[DiscordChannelCategory.WorldNews] = 42; + messenger.Channels[DiscordChannelCategory.WorldNews] = 42; await using (var notifier = CreateNotifier(handler, settings, messenger)) { await notifier.OnGameEventAsync(new BossKilledEvent(0, DateTime.UtcNow, "Hero", "Kundun", "Kalima 7")).ConfigureAwait(false); @@ -93,6 +92,22 @@ public async Task BotChannelIsPreferredToWebhookAsync() Assert.That(handler.Requests, Is.Empty); } + /// + /// Tests that a category without channel of the bot is posted by the webhook, e.g. before the bot found the channels of the layout. + /// + [Test] + public async Task WebhookIsUsedWithoutBotChannelAsync() + { + var handler = new RecordingHttpMessageHandler(); + var messenger = new RecordingMessenger(); + await using var notifier = CreateNotifier(handler, new DiscordSettings(), messenger); + + await notifier.OnGameEventAsync(new BossKilledEvent(0, DateTime.UtcNow, "Hero", "Kundun", "Kalima 7")).ConfigureAwait(false); + + await handler.WaitForRequestsAsync(1).ConfigureAwait(false); + Assert.That(messenger.Messages, Is.Empty); + } + private static DiscordNotifier CreateNotifier(RecordingHttpMessageHandler handler, DiscordSettings settings, IDiscordMessenger? botMessenger = null) { // There is no webhook for the notices, so they aren't posted. diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordServerLayoutTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordServerLayoutTest.cs new file mode 100644 index 0000000000..db560a09e1 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordServerLayoutTest.cs @@ -0,0 +1,71 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Discord; + +using System.IO; +using System.Text; +using MUnique.OpenMU.Discord; +using MUnique.OpenMU.Discord.Provisioning; + +/// +/// Tests for the . +/// +[TestFixture] +public class DiscordServerLayoutTest +{ + /// + /// Tests that the default layout is valid and has a channel for each category of notifications and for the status. + /// + [Test] + public void DefaultLayoutIsComplete() + { + var layout = DiscordServerLayout.LoadDefault(); + + Assert.That(layout.Validate(), Is.Empty); + Assert.That(layout.Channels.SelectMany(c => c.Notifications).Distinct(), Is.EquivalentTo(Enum.GetValues())); + Assert.That(layout.Channels.Count(c => c.ShowsStatus), Is.EqualTo(1)); + } + + /// + /// Tests that a layout with a duplicate key and an unknown role isn't loaded. + /// + [Test] + public void InvalidLayoutIsRejected() + { + const string json = """ + { + "categories": [ + { "key": "a", "name": "A", "visibleTo": [ "gm" ], "channels": [ { "key": "x", "name": "x" }, { "key": "X", "name": "y" } ] } + ] + } + """; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + var exception = Assert.Throws(() => DiscordServerLayout.Load(stream)); + + Assert.That(exception!.Message, Does.Contain("unknown role 'gm'").And.Contain("'X' isn't unique")); + } + + /// + /// Tests that a layout with two channels of the same name isn't loaded, because the channels are adopted by their name. + /// + [Test] + public void DuplicateNameIsRejected() + { + const string json = """ + { + "categories": [ + { "key": "a", "name": "A", "channels": [ { "key": "x", "name": "chat" } ] }, + { "key": "b", "name": "B", "channels": [ { "key": "y", "name": "Chat" } ] } + ] + } + """; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + var exception = Assert.Throws(() => DiscordServerLayout.Load(stream)); + + Assert.That(exception!.Message, Does.Contain("The channel name 'Chat' of 'y' isn't unique.")); + } +} diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordServerProvisionerTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordServerProvisionerTest.cs new file mode 100644 index 0000000000..1affd978f8 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordServerProvisionerTest.cs @@ -0,0 +1,177 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Discord; + +using Microsoft.Extensions.Logging.Abstractions; +using MUnique.OpenMU.Discord; +using MUnique.OpenMU.Discord.Provisioning; + +/// +/// Tests for the . +/// +[TestFixture] +public class DiscordServerProvisionerTest +{ + private readonly DiscordServerProvisioner _provisioner = new(NullLogger.Instance); + + /// + /// Tests that everything of the layout is created on an empty Discord server. + /// + [Test] + public async Task EmptyServerIsSetUpAsync() + { + var server = new FakeDiscordServer(); + var layout = DiscordServerLayout.LoadDefault(); + + var result = await this._provisioner.ProvisionAsync(server, layout).ConfigureAwait(false); + + Assert.That(result.Failed, Is.Empty); + Assert.That(result.Adopted, Is.Empty); + Assert.That(result.Created, Has.Count.EqualTo(layout.Roles.Count + layout.Categories.Count + layout.Channels.Count())); + Assert.That(result.ChannelIds.Keys, Is.EquivalentTo(layout.Channels.Select(c => c.Key))); + Assert.That(server.Channels.Single(c => c.Name == "events").CategoryId, Is.EqualTo(server.GetId("Info"))); + } + + /// + /// Tests that the setup can be repeated without creating anything twice. + /// + [Test] + public async Task SetupIsIdempotentAsync() + { + var server = new FakeDiscordServer(); + var layout = DiscordServerLayout.LoadDefault(); + var first = await this._provisioner.ProvisionAsync(server, layout).ConfigureAwait(false); + var createdCount = server.CreatedCount; + + var second = await this._provisioner.ProvisionAsync(server, layout).ConfigureAwait(false); + + Assert.That(server.CreatedCount, Is.EqualTo(createdCount)); + Assert.That(second.Created, Is.Empty); + Assert.That(second.Adopted, Is.EquivalentTo(first.Created)); + Assert.That(second.ChannelIds, Is.EquivalentTo(first.ChannelIds)); + } + + /// + /// Tests that existing channels are adopted by their name, case-insensitive, even when they are in another category. + /// + [Test] + public async Task ExistingChannelsAreAdoptedAsync() + { + var server = new FakeDiscordServer(); + var otherCategoryId = server.AddCategory("Other"); + var eventsId = server.AddChannel("Events", otherCategoryId); + var generalId = server.AddChannel("general", null); + + var result = await this._provisioner.ProvisionAsync(server, DiscordServerLayout.LoadDefault()).ConfigureAwait(false); + + Assert.That(result.ChannelIds["events"], Is.EqualTo(eventsId)); + Assert.That(result.ChannelIds["general"], Is.EqualTo(generalId)); + Assert.That(result.Adopted, Is.EquivalentTo(new[] { "#events", "#general" })); + Assert.That(server.Channels.Count(c => string.Equals(c.Name, "events", StringComparison.OrdinalIgnoreCase)), Is.EqualTo(1)); + } + + /// + /// Tests that a failure is reported, and that the setup continues with the rest. + /// + [Test] + public async Task FailureIsReportedAndSetupContinuesAsync() + { + var server = new FakeDiscordServer { FailingName = "Staff" }; + + var result = await this._provisioner.ProvisionAsync(server, DiscordServerLayout.LoadDefault()).ConfigureAwait(false); + + Assert.That(result.Failed, Is.EqualTo(new[] { ("Staff", "Missing Permissions") })); + Assert.That(result.ChannelIds.Keys, Does.Contain("world-news")); + Assert.That(result.ChannelIds.Keys, Does.Not.Contain("staff-alerts")); + } + + /// + /// Tests the permissions: Read-only channels can only be written by the bot, and the staff category can only be seen by the game masters and the bot. + /// The overwrite of the bot comes first, so that the bot doesn't lock itself out of a private channel before its own overwrite is set. + /// + [Test] + public async Task PermissionsAreAppliedAsync() + { + var server = new FakeDiscordServer(); + var result = await this._provisioner.ProvisionAsync(server, DiscordServerLayout.LoadDefault()).ConfigureAwait(false); + var gmRoleId = server.Roles.Single(r => r.Name == "GM").Id; + const DiscordChannelAccess viewAndSend = DiscordChannelAccess.View | DiscordChannelAccess.Send; + + Assert.That(server.Overwrites[result.ChannelIds["events"]], Is.EqualTo(new DiscordPermissionOverwrite[] + { + new(server.BotUserId, false, viewAndSend, DiscordChannelAccess.None), + new(server.EveryoneRoleId, true, DiscordChannelAccess.None, DiscordChannelAccess.Send), + })); + Assert.That(server.Overwrites.ContainsKey(result.ChannelIds["general"]), Is.False); + Assert.That(server.Overwrites[server.GetId("Staff")], Is.EqualTo(new DiscordPermissionOverwrite[] + { + new(server.BotUserId, false, viewAndSend, DiscordChannelAccess.None), + new(server.EveryoneRoleId, true, DiscordChannelAccess.None, DiscordChannelAccess.View), + new(gmRoleId, true, viewAndSend, DiscordChannelAccess.None), + })); + Assert.That(server.Overwrites[result.ChannelIds["staff-alerts"]], Is.EqualTo(new DiscordPermissionOverwrite[] + { + new(server.BotUserId, false, viewAndSend, DiscordChannelAccess.None), + new(server.EveryoneRoleId, true, DiscordChannelAccess.None, viewAndSend), + new(gmRoleId, true, DiscordChannelAccess.View, DiscordChannelAccess.None), + })); + } + + /// + /// Tests that a failed staff role never creates public staff channels. + /// + [Test] + public async Task PrivateCategoryWithoutRolesIsSkippedAsync() + { + var server = new FakeDiscordServer { FailingName = "GM" }; + var result = await this._provisioner.ProvisionAsync(server, DiscordServerLayout.LoadDefault()).ConfigureAwait(false); + Assert.That(result.PublicCategories, Is.Empty); + Assert.That(server.Channels.Any(channel => channel.Name == "Staff"), Is.False); + Assert.That(result.ChannelIds.ContainsKey("staff-alerts"), Is.False); + Assert.That(result.Failed.Any(failure => failure.Name == "Staff"), Is.True); + } + + /// + /// Tests that failed staff permissions never enable notification routing. + /// + [TestCase("Staff")] + [TestCase("staff-alerts")] + public async Task FailedPrivatePermissionsAreNotRoutedAsync(string name) + { + var server = new FakeDiscordServer { FailingPermissionsName = name }; + var layout = DiscordServerLayout.LoadDefault(); + var result = await this._provisioner.ProvisionAsync(server, layout).ConfigureAwait(false); + Assert.That(result.ChannelIds.ContainsKey("staff-alerts"), Is.False); + Assert.That(DiscordServerProvisioner.FindChannels(layout, server.GetChannels()).ContainsKey("staff-alerts"), Is.False); + } + + /// + /// Tests that an old public staff channel is not adopted for delivery by name. + /// + [Test] + public void PublicStaffChannelIsNotRouted() + { + var server = new FakeDiscordServer(); + server.AddChannel("staff-alerts", server.AddCategory("Staff")); + Assert.That(DiscordServerProvisioner.FindChannels(DiscordServerLayout.LoadDefault(), server.GetChannels()), Is.Empty); + } + + /// + /// Tests that the channels of the layout are found by their name. + /// + [Test] + public void ChannelsAreFoundByName() + { + var server = new FakeDiscordServer(); + var infoId = server.AddCategory("INFO"); + var statusId = server.AddChannel("server-status", infoId); + server.AddChannel("server-status", null); + var newsId = server.AddChannel("world-news", null); + + var channels = DiscordServerProvisioner.FindChannels(DiscordServerLayout.LoadDefault(), server.GetChannels()); + + Assert.That(channels, Is.EquivalentTo(new Dictionary { { "server-status", statusId }, { "world-news", newsId } })); + } +} diff --git a/tests/MUnique.OpenMU.Tests/Discord/FakeDiscordServer.cs b/tests/MUnique.OpenMU.Tests/Discord/FakeDiscordServer.cs new file mode 100644 index 0000000000..a002fac230 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Discord/FakeDiscordServer.cs @@ -0,0 +1,141 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Discord; + +using MUnique.OpenMU.Discord.Provisioning; + +/// +/// A in memory. +/// +internal sealed class FakeDiscordServer : IDiscordServer +{ + private ulong _nextId = 1000; + + /// + public ulong EveryoneRoleId => 1; + + /// + public ulong BotUserId => 2; + + /// + /// Gets the roles. + /// + public List Roles { get; } = new(); + + /// + /// Gets the channels and categories. + /// + public List Channels { get; } = new(); + + /// + /// Gets the applied overwrites per channel. + /// + public Dictionary> Overwrites { get; } = new(); + + /// + /// Gets the number of created roles, categories and channels. + /// + public int CreatedCount { get; private set; } + + /// + /// Gets or sets the name of a role, category or channel which can't be created. + /// + public string? FailingName { get; set; } + + /// + /// Gets or sets the name of a channel whose permissions cannot be applied. + /// + public string? FailingPermissionsName { get; set; } + + /// + /// Adds an existing category. + /// + /// The name. + /// The identifier. + public ulong AddCategory(string name) + { + var id = this._nextId++; + this.Channels.Add(new DiscordChannelInfo(id, name, true, null)); + return id; + } + + /// + /// Adds an existing channel. + /// + /// The name. + /// The identifier of the category. + /// The identifier. + public ulong AddChannel(string name, ulong? categoryId) + { + var id = this._nextId++; + this.Channels.Add(new DiscordChannelInfo(id, name, false, categoryId)); + return id; + } + + /// + /// Gets the identifier of a channel or category by its name. + /// + /// The name. + /// The identifier. + public ulong GetId(string name) => this.Channels.Single(c => c.Name == name).Id; + + /// + public IReadOnlyCollection GetRoles() => this.Roles.ToList(); + + /// + public IReadOnlyCollection GetChannels() => this.Channels + .Select(channel => channel with + { + IsPrivate = this.Overwrites.TryGetValue(channel.Id, out var overwrites) + && overwrites.Any(overwrite => overwrite.TargetId == this.EveryoneRoleId && overwrite.Deny.HasFlag(DiscordChannelAccess.View)), + }) + .ToList(); + + /// + public Task CreateRoleAsync(DiscordRoleLayout role) + { + this.ThrowIfFailing(role.Name); + var id = this._nextId++; + this.Roles.Add(new DiscordRoleInfo(id, role.Name)); + this.CreatedCount++; + return Task.FromResult(id); + } + + /// + public Task CreateCategoryAsync(DiscordCategoryLayout category) + { + this.ThrowIfFailing(category.Name); + this.CreatedCount++; + return Task.FromResult(this.AddCategory(category.Name)); + } + + /// + public Task CreateChannelAsync(DiscordChannelLayout channel, ulong? categoryId) + { + this.ThrowIfFailing(channel.Name); + this.CreatedCount++; + return Task.FromResult(this.AddChannel(channel.Name, categoryId)); + } + + /// + public Task ApplyPermissionsAsync(ulong channelId, IReadOnlyList overwrites) + { + if (this.Channels.Single(channel => channel.Id == channelId).Name == this.FailingPermissionsName) + { + throw new InvalidOperationException("Missing Permissions"); + } + + this.Overwrites[channelId] = overwrites; + return Task.CompletedTask; + } + + private void ThrowIfFailing(string name) + { + if (name == this.FailingName) + { + throw new InvalidOperationException("Missing Permissions"); + } + } +} diff --git a/tests/MUnique.OpenMU.Tests/Discord/RecordingMessenger.cs b/tests/MUnique.OpenMU.Tests/Discord/RecordingMessenger.cs index 11e754d021..84587cc397 100644 --- a/tests/MUnique.OpenMU.Tests/Discord/RecordingMessenger.cs +++ b/tests/MUnique.OpenMU.Tests/Discord/RecordingMessenger.cs @@ -17,6 +17,14 @@ internal sealed class RecordingMessenger : IDiscordMessenger /// public List<(ulong ChannelId, IReadOnlyList Embeds)> Messages { get; } = new(); + /// + /// Gets the channels per category. + /// + public Dictionary Channels { get; } = new(); + + /// + public ulong? GetChannelId(DiscordChannelCategory category) => this.Channels.TryGetValue(category, out var channelId) ? channelId : null; + /// public Task SendAsync(ulong channelId, IReadOnlyList embeds, CancellationToken cancellationToken) {