diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 9682cc76ed..45bdac25e8 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -54,6 +54,8 @@ jobs: dockerfile: src/Dapr/CentralServer.Host/Dockerfile - image: openmu-game dockerfile: src/Dapr/GameServer.Host/Dockerfile + - image: openmu-discord + dockerfile: src/Dapr/Discord.Host/Dockerfile steps: - uses: actions/checkout@v4 diff --git a/deploy/distributed/docker-compose.yml b/deploy/distributed/docker-compose.yml index 4ec33a85c5..61eef2b7b3 100644 --- a/deploy/distributed/docker-compose.yml +++ b/deploy/distributed/docker-compose.yml @@ -214,6 +214,51 @@ services: "-config", "/components/config.yaml", "-placement-host-address", "dapr-placement:50006" ] + # The Discord bot and notifications. It's only started with the profile "discord", + # e.g. "docker compose --profile discord up -d". See the documentation for the + # further settings, like the webhooks and channels, which are added as environment variables. + discord: + image: munique/openmu-discord + container_name: discord + restart: unless-stopped + profiles: [ "discord" ] + healthcheck: *openmu-healthcheck + build: + context: ../../src + dockerfile: Dapr/Discord.Host/Dockerfile + depends_on: + discord-dapr: + condition: service_started + centralServer: + condition: service_started + database: + condition: service_healthy + ports: + - "8080" + environment: + DAPR_HTTP_ENDPOINT: http://discord-dapr:3500 + DAPR_GRPC_ENDPOINT: http://discord-dapr:50001 + ASPNETCORE_URLS: http://+:8080 + OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-lgtm:4317 + DB_HOST: ${DB_HOST:-database} + DB_ADMIN_USER: ${DB_ADMIN_USER:-} + DB_ADMIN_PW: ${DB_ADMIN_PW:-} + Discord__Bot__Token: ${DISCORD_BOT_TOKEN:-} + Discord__Language: ${DISCORD_LANGUAGE:-en} + + discord-dapr: + <<: *dapr-sidecar + container_name: discord_dapr + profiles: [ "discord" ] + environment: + POD_NAME: discord + command: [ "./daprd", "-app-id", "discord", "-app-port", "8080", + "-app-channel-address", "discord", + "-dapr-listen-addresses", "0.0.0.0", + "-components-path", "/components", + "-config", "/components/config.yaml", + "-placement-host-address", "dapr-placement:50006" ] + gameServer0: image: munique/openmu-game container_name: gameServer0 diff --git a/docs-website/docs/admin-panel/plugins.md b/docs-website/docs/admin-panel/plugins.md index 787b4ef213..8e5dd75087 100644 --- a/docs-website/docs/admin-panel/plugins.md +++ b/docs-website/docs/admin-panel/plugins.md @@ -94,7 +94,7 @@ documented separately: The *Game Event Publisher* plugin publishes notable events of the game to the whole server, so that external systems can react on them, for example a Discord -integration (see [Discord notifications](../server-features/discord.md)). It's +integration (see [Discord](../server-features/discord.md)). It's disabled by default; activate it when such a system is connected. It publishes: diff --git a/docs-website/docs/deployment/distributed.md b/docs-website/docs/deployment/distributed.md index 1278b36d38..edb964a09f 100644 --- a/docs-website/docs/deployment/distributed.md +++ b/docs-website/docs/deployment/distributed.md @@ -123,6 +123,17 @@ docker compose restart centralServer gameServer0 gameServer1 ``` ::: +### Discord + +The Discord bot and the Discord notifications run in their own container, +`discord`. It's only started with the `discord` profile: + +```bash +DISCORD_BOT_TOKEN=... docker compose --profile discord up -d +``` + +See [Discord](../server-features/discord.md) for how to set it up. + ## Differences to the all-in-one deployment Some functions of the admin panel behave differently, because the panel runs in diff --git a/docs-website/docs/server-features/discord.md b/docs-website/docs/server-features/discord.md index 094d62dade..2ffe77df81 100644 --- a/docs-website/docs/server-features/discord.md +++ b/docs-website/docs/server-features/discord.md @@ -1,13 +1,22 @@ --- -title: Discord notifications +title: Discord sidebar_label: Discord sidebar_position: 5 -description: Post events of the game, like mini games, castle siege and rare drops, to Discord channels. +description: A Discord bot and notifications, which bring the game to your Discord server. --- -# Discord notifications +# Discord -OpenMU can post what happens in the game to the channels of your Discord server: +OpenMU can bring your game into your Discord server, in two ways which can be +combined: + +* **Notifications:** what happens in the game, like mini games, the castle siege + 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`. + +## Notifications | Category | What is posted | |---|---| @@ -16,22 +25,22 @@ OpenMU can post what happens in the game to the channels of your Discord server: | `WorldNews` | Boss kills, excellent and ancient items which monsters dropped, characters which reach a level milestone | | `Notices` | Global notices which game masters send with the `!` chat prefix | -Each category is posted to its own channel through a Discord *webhook*. Categories -without webhook aren't posted. +Each category is posted into its own channel. A category without channel isn't +posted. + +For the notifications, the *Game Event Publisher* plugin has to be active +([Plugins](../admin-panel/plugins.md#game-event-publisher)). It's disabled by +default. In its configuration, you choose which events are published, which +monsters are bosses and which levels are milestones. -:::note -This works in the all-in-one deployment. In the distributed deployment, the -events are already published, but there is no service yet which posts them to -Discord. -::: +### With webhooks -## Setting it up +Webhooks are the simplest way, because you don't need a bot: -1. **Create the webhooks.** In Discord, open the settings of a channel, go to - *Integrations → Webhooks*, create a webhook and copy its URL. Do this for - each channel which should receive messages. -2. **Configure the webhooks** for the server, through environment variables, - e.g. in your `docker-compose.override.yml`: +1. In Discord, open the settings of a channel, go to *Integrations → Webhooks*, + create a webhook and copy its URL. Do this for each channel which should + receive messages. +2. Configure the URLs, e.g. as environment variables: ```yaml environment: @@ -41,38 +50,93 @@ Discord. - Discord__Webhooks__Notices=https://discord.com/api/webhooks/... ``` - Treat the URLs like passwords: everybody who knows one can post into the - channel. That's why they are not part of the game configuration in the - database, but of the configuration of the server process. -3. **Activate the *Game Event Publisher* plugin** in the admin panel - ([Plugins](../admin-panel/plugins.md#game-event-publisher)). It's disabled by - default. In its configuration, you choose which events are published, which - monsters are bosses and which levels are milestones. -4. Restart the server. +Treat the URLs like passwords: everybody who knows one can post into the channel. +That's why they are not part of the game configuration in the database, but of +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. + +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*. + +## The bot + +### Creating the bot + +1. Open the [Discord developer portal](https://discord.com/developers/applications), + create a *New Application* and give it the name of your server. +2. Under *Bot*, click *Reset Token* and copy the token. Treat it like a password. + 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. +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. + +In the distributed deployment, the bot runs in its own container, see +[Distributed deployment](../deployment/distributed.md#discord). + +### Slash commands + +| Command | Shows | +|---|---| +| `/online` | The online players of each game server, and in total | +| `/who ` | The class, the levels and the online state of a character. Characters which are invisible in the game appear offline. | +| `/guild ` | The guild master, the number of members and the members which are online | +| `/events` | The events of the next 24 hours, like mini games and invasions | +| `/rank` | The ten best characters by resets, master level and level | + +The commands only show information; they don't change anything in the game. + +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. ## Settings All settings are part of the `Discord` section of the configuration, so they can -also be set in the `appsettings.json` of the server. +also be set in the `appsettings.json` of the server. As environment variables, +the parts are separated by two underscores. | Setting | Default | Description | |---|---|---| | `Webhooks__` | — | The webhook URL per category, see the table above. | -| `Language` | `en` | The language of the messages. English (`en`) and German (`de`) are available. Names of maps, monsters and items are translated, too. | +| `Language` | `en` | The language of the messages and the bot. English (`en`) and German (`de`) are available. Names of maps, monsters and items are translated, too. | | `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 webhook which can wait to be sent. When there are more, the oldest ones are dropped. | +| `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__StatusUpdateInterval` | `00:01:00` | How often the status message and the presence are updated. | ## Good to know * **Times** like "the entrance closes in 5 minutes" are posted in a way that each Discord user sees them in their own time zone and language. -* **Rate limits:** Discord accepts about 30 messages per minute per webhook. +* **Rate limits:** Discord accepts about 30 messages per minute per channel. When more happens, OpenMU combines up to 10 messages into one post and waits for Discord, so nothing gets lost unless the queue overflows. * **Mini games** like Blood Castle open all their levels at the same time; this is announced only once. * **Mentions** are disabled, so a text of a player like `@everyone` can't ping anybody. +* When Discord rejects the token, the bot stops and logs an error, instead of + 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. diff --git a/src/Dapr/CentralServer.Host/FriendServerController.cs b/src/Dapr/CentralServer.Host/FriendServerController.cs index 32a9acf0d1..78ef5428ce 100644 --- a/src/Dapr/CentralServer.Host/FriendServerController.cs +++ b/src/Dapr/CentralServer.Host/FriendServerController.cs @@ -51,6 +51,17 @@ public async Task FriendResponseAsync([FromBody] FriendResponseArguments data) await this._friendServer.FriendResponseAsync(data.CharacterName, data.FriendName, data.Accepted).ConfigureAwait(false); } + /// + /// Gets the identifier of the game server on which the character is online. + /// + /// The name of the character. + /// The identifier of the game server; or , if the character is offline or invisible. + [HttpPost(nameof(IFriendServer.GetOnlineServerIdAsync))] + public async Task GetOnlineServerIdAsync([FromBody] string characterName) + { + return await this._friendServer.GetOnlineServerIdAsync(characterName).ConfigureAwait(false); + } + /// /// Determines whether two players are friends. /// diff --git a/src/Dapr/Discord.Host/Dockerfile b/src/Dapr/Discord.Host/Dockerfile new file mode 100644 index 0000000000..c389ec60ba --- /dev/null +++ b/src/Dapr/Discord.Host/Dockerfile @@ -0,0 +1,27 @@ +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS base +WORKDIR /app + +# OpenMU uses localized texts, which require the culture data of ICU. +# The alpine images don't contain it and run in the globalization-invariant mode by default. +RUN apk add --no-cache icu-libs +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false +EXPOSE 8080 + +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build +WORKDIR /src +COPY ["Directory.Packages.props", "."] +COPY ["Directory.Build.props", "."] +COPY ["Dapr/Discord.Host/MUnique.OpenMU.Discord.Host.csproj", "Dapr/Discord.Host/"] +RUN dotnet restore "Dapr/Discord.Host/MUnique.OpenMU.Discord.Host.csproj" +COPY . . +WORKDIR "/src/Dapr/Discord.Host" +RUN dotnet build "MUnique.OpenMU.Discord.Host.csproj" -c Release -o /app/build -p:ci=true + +FROM build AS publish +RUN dotnet publish "MUnique.OpenMU.Discord.Host.csproj" -c Release -o /app/publish -p:ci=true + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +USER $APP_UID +ENTRYPOINT ["dotnet", "MUnique.OpenMU.Discord.Host.dll"] diff --git a/src/Dapr/Discord.Host/GameEventController.cs b/src/Dapr/Discord.Host/GameEventController.cs new file mode 100644 index 0000000000..1c5e7561cb --- /dev/null +++ b/src/Dapr/Discord.Host/GameEventController.cs @@ -0,0 +1,54 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.Host; + +using global::Dapr; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.Interfaces; + +/// +/// Receives the s which the game servers publish, and passes them to the s. +/// +[ApiController] +[Route("events")] +public class GameEventController : ControllerBase +{ + private readonly IEnumerable _listeners; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The listeners. + /// The logger. + public GameEventController(IEnumerable listeners, ILogger logger) + { + this._listeners = listeners; + this._logger = logger; + } + + /// + /// Passes the game event to the listeners. + /// + /// The game event. + /// The task. + [Topic("pubsub", nameof(IEventPublisher.GameEventAsync))] + [HttpPost(nameof(IEventPublisher.GameEventAsync))] + public async Task GameEventAsync([FromBody] GameEvent gameEvent) + { + foreach (var listener in this._listeners) + { + try + { + await listener.OnGameEventAsync(gameEvent).ConfigureAwait(false); + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error when passing the game event {gameEvent} to {listener}.", gameEvent, listener); + } + } + } +} diff --git a/src/Dapr/Discord.Host/MUnique.OpenMU.Discord.Host.csproj b/src/Dapr/Discord.Host/MUnique.OpenMU.Discord.Host.csproj new file mode 100644 index 0000000000..2c2b213382 --- /dev/null +++ b/src/Dapr/Discord.Host/MUnique.OpenMU.Discord.Host.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + false + false + enable + nullable;CS4014;VSTHRD110;VSTHRD100 + True + + ..\..\..\deploy\distributed\docker-compose.dcproj + 4b3c355b-89d4-4bb3-b612-57b3e826b0dd + Linux + ..\.. + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/src/Dapr/Discord.Host/Program.cs b/src/Dapr/Discord.Host/Program.cs new file mode 100644 index 0000000000..5f7330d2e1 --- /dev/null +++ b/src/Dapr/Discord.Host/Program.cs @@ -0,0 +1,34 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using MUnique.OpenMU.Dapr.Common; +using MUnique.OpenMU.Discord; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.PlugIns; +using MUnique.OpenMU.ServerClients; + +var builder = DaprService.CreateBuilder("Discord", args); + +var plugInConfigurations = new List(); +var settings = builder.Configuration.GetSection(DiscordSettings.SectionName).Get() ?? new DiscordSettings(); + +builder.Services + .AddPeristenceProvider() + .AddPlugInManager(plugInConfigurations) + .AddManageableServerRegistry() + .AddSingleton() + .AddSingleton() + + // The game servers of the distributed deployment run in UTC, too. + .AddDiscord(settings, () => TimeZoneInfo.Utc); + +var app = builder.BuildAndConfigure(); + +await app.WaitForDatabaseConnectionInitializationAsync().ConfigureAwait(false); +await app.Services.TryLoadPlugInConfigurationsAsync(plugInConfigurations).ConfigureAwait(false); + +app.Run(); diff --git a/src/Dapr/Discord.Host/Properties/AssemblyInfo.cs b/src/Dapr/Discord.Host/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..6e3a1ca2d8 --- /dev/null +++ b/src/Dapr/Discord.Host/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +using System.Reflection; + +[assembly: AssemblyTitle("MUnique.OpenMU.Discord.Host")] diff --git a/src/Dapr/Discord.Host/appsettings.json b/src/Dapr/Discord.Host/appsettings.json new file mode 100644 index 0000000000..4faf77acf9 --- /dev/null +++ b/src/Dapr/Discord.Host/appsettings.json @@ -0,0 +1,3 @@ +{ + "AllowedHosts": "*" +} diff --git a/src/Dapr/ServerClients/FriendServer.cs b/src/Dapr/ServerClients/FriendServer.cs index 4519738388..ec685c0d75 100644 --- a/src/Dapr/ServerClients/FriendServer.cs +++ b/src/Dapr/ServerClients/FriendServer.cs @@ -150,5 +150,19 @@ public async ValueTask InviteFriendToChatRoomAsync(string selectedCharacte } } + /// + public async ValueTask GetOnlineServerIdAsync(string characterName) + { + try + { + return await this._daprClient.InvokeMethodAsync(this._targetAppId, GetMethodName(nameof(this.GetOnlineServerIdAsync)), characterName).ConfigureAwait(false); + } + catch (Exception ex) + { + this._logger.LogError(ex, "Unexpected error when getting the online state of a character."); + return null; + } + } + private static string GetMethodName(string name) => $"{CentralServer.FriendServerRoute}/{name}"; } diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 9a7f8ad720..cf92bd303e 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -10,6 +10,7 @@ + diff --git a/src/Discord/CharacterInfo.cs b/src/Discord/CharacterInfo.cs new file mode 100644 index 0000000000..831d241a87 --- /dev/null +++ b/src/Discord/CharacterInfo.cs @@ -0,0 +1,16 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord; + +/// +/// Information about a character. +/// +/// The name. +/// The name of the class, as value of a localized string. +/// The level. +/// The master level. +/// The resets. +/// The name of the game server on which the character is online; , if offline. +public sealed record CharacterInfo(string Name, string ClassName, int Level, int MasterLevel, int Resets, string? OnlineServerName); diff --git a/src/Discord/DiscordBot.cs b/src/Discord/DiscordBot.cs new file mode 100644 index 0000000000..0d21060518 --- /dev/null +++ b/src/Discord/DiscordBot.cs @@ -0,0 +1,400 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord; + +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Threading; +using global::Discord; +using global::Discord.WebSocket; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.Discord.Properties; +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. +/// +/// +/// It only needs the intent, and no privileged one, +/// because it reacts on slash commands instead of reading messages. +/// +public sealed class DiscordBot : IManageableServer, IDiscordMessenger, IAsyncDisposable +{ + private readonly DiscordBotSettings _settings; + private readonly DiscordCommands _commands; + private readonly DiscordStatusFormatter _statusFormatter; + private readonly IDiscordGameDataProvider _data; + private readonly ILogger _logger; + private readonly SemaphoreSlim _lifecycleLock = new(1, 1); + + private DiscordSocketClient? _client; + private TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously); + private CancellationTokenSource? _statusLoopCancellation; + private ulong? _statusMessageId; + private ServerState _serverState = ServerState.Stopped; + private int _isDisposed; + + /// + /// Initializes a new instance of the class. + /// + /// The settings of the bot. + /// The slash commands. + /// The formatter of the status. + /// The provider of the data of the game. + /// The logger. + public DiscordBot(DiscordBotSettings settings, DiscordCommands commands, DiscordStatusFormatter statusFormatter, IDiscordGameDataProvider data, ILogger logger) + { + this._settings = settings; + this._commands = commands; + this._statusFormatter = statusFormatter; + this._data = data; + this._logger = logger; + } + + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// + public int Id => 0; + + /// + public Guid ConfigurationId => Guid.Empty; + + /// + public string Description => "Discord Bot"; + + /// + public ServerType Type => ServerType.Discord; + + /// + public ServerState ServerState + { + get => this._serverState; + private set + { + if (this._serverState != value) + { + this._serverState = value; + this.OnPropertyChanged(); + } + } + } + + /// + /// The bot has no connection limit; it's in as many Discord servers as it gets invited to. + public int MaximumConnections => int.MaxValue; + + /// + /// The number of the Discord servers which the bot is in. + public int CurrentConnections => this._client?.Guilds.Count ?? 0; + + /// + public async ValueTask StartAsync() + { + await this._lifecycleLock.WaitAsync().ConfigureAwait(false); + try + { + if (this._client is not null) + { + return; + } + + this.ServerState = ServerState.Starting; + if (this._settings.StatusChannelId is { } statusChannelId && this._settings.Channels.ContainsValue(statusChannelId)) + { + this._logger.LogWarning("The status channel {channelId} is also configured for game events. Use a channel in which only the status is shown, otherwise the status message is posted again when the events push it out of the channel.", statusChannelId); + } + + this._ready = new(TaskCreationOptions.RunContinuationsAsynchronously); + var client = new DiscordSocketClient(new DiscordSocketConfig { GatewayIntents = GatewayIntents.Guilds }); + client.Log += this.OnLogAsync; + client.Ready += () => this.OnReadyAsync(client); + client.SlashCommandExecuted += this.OnSlashCommandExecutedAsync; + this._client = client; + + await client.LoginAsync(TokenType.Bot, this._settings.Token).ConfigureAwait(false); + await client.StartAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + this._logger.LogError(ex, "The Discord bot couldn't be started. Is the token valid?"); + await this.StopClientAsync().ConfigureAwait(false); + } + finally + { + this._lifecycleLock.Release(); + } + } + + /// + public async ValueTask ShutdownAsync() + { + await this._lifecycleLock.WaitAsync().ConfigureAwait(false); + try + { + this.ServerState = ServerState.Stopping; + await this.StopClientAsync().ConfigureAwait(false); + } + finally + { + this._lifecycleLock.Release(); + } + } + + /// + Task Microsoft.Extensions.Hosting.IHostedService.StartAsync(CancellationToken cancellationToken) => this.StartAsync().AsTask(); + + /// + Task Microsoft.Extensions.Hosting.IHostedService.StopAsync(CancellationToken cancellationToken) => this.ShutdownAsync().AsTask(); + + /// + public async Task SendAsync(ulong channelId, IReadOnlyList embeds, CancellationToken cancellationToken) + { + await this._ready.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + if (await this.GetMessageChannelAsync(channelId).ConfigureAwait(false) is not { } channel) + { + this._logger.LogError("The Discord channel {channelId} isn't available for the bot.", channelId); + return; + } + + await channel.SendMessageAsync(embeds: embeds.Select(ToDiscordEmbed).ToArray(), allowedMentions: AllowedMentions.None).ConfigureAwait(false); + } + + /// + /// + /// It may be called more than once, e.g. by a dependency injection container which knows the bot as singleton and as hosted service. + /// The lock isn't disposed, because a without wait handle doesn't hold any resources. + /// + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref this._isDisposed, 1) == 0) + { + await this.ShutdownAsync().ConfigureAwait(false); + } + } + + private static Embed ToDiscordEmbed(DiscordEmbed embed) + { + var builder = new EmbedBuilder() + .WithTitle(embed.Title) + .WithDescription(embed.Description) + .WithColor(new Color((uint)embed.Color)) + .WithTimestamp(new DateTimeOffset(DateTime.SpecifyKind(embed.TimestampUtc, DateTimeKind.Utc))); + if (embed.Footer is { } footer) + { + builder.WithFooter(footer); + } + + return builder.Build(); + } + + private async ValueTask StopClientAsync() + { + if (this._statusLoopCancellation is { } cancellation) + { + await cancellation.CancelAsync().ConfigureAwait(false); + cancellation.Dispose(); + this._statusLoopCancellation = null; + } + + if (this._client is { } client) + { + this._client = null; + try + { + await client.StopAsync().ConfigureAwait(false); + await client.LogoutAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + this._logger.LogWarning(ex, "Error when stopping the Discord bot."); + } + + await client.DisposeAsync().ConfigureAwait(false); + } + + this._statusMessageId = null; + this.ServerState = ServerState.Stopped; + } + + private async Task OnReadyAsync(DiscordSocketClient client) + { + try + { + await this.RegisterCommandsAsync(client).ConfigureAwait(false); + } + catch (Exception ex) + { + this._logger.LogError(ex, "The slash commands of the Discord bot couldn't be registered."); + } + + this.ServerState = ServerState.Started; + this._ready.TrySetResult(); + + if (this._statusLoopCancellation is null) + { + this._statusLoopCancellation = new CancellationTokenSource(); + _ = this.RunStatusLoopAsync(this._statusLoopCancellation.Token); + } + } + + private async Task RegisterCommandsAsync(DiscordSocketClient client) + { + var commands = DiscordCommands.Definitions.Select(definition => + { + var builder = new SlashCommandBuilder() + .WithName(definition.Name) + .WithDescription(this._commands.Text(definition.DescriptionKey)); + if (definition.OptionName is { } optionName) + { + builder.AddOption(optionName, ApplicationCommandOptionType.String, this._commands.Text(definition.OptionDescriptionKey ?? optionName), isRequired: true); + } + + return (ApplicationCommandProperties)builder.Build(); + }).ToArray(); + + if (this._settings.GuildId is { } guildId && client.GetGuild(guildId) is { } guild) + { + await guild.BulkOverwriteApplicationCommandAsync(commands).ConfigureAwait(false); + } + else + { + if (this._settings.GuildId is { } missingGuildId) + { + this._logger.LogWarning("The bot isn't on the configured Discord server {guildId}, so the slash commands are registered globally, which can take up to an hour. Is the identifier correct, and was the bot invited?", missingGuildId); + } + + await ((IDiscordClient)client).BulkOverwriteGlobalApplicationCommand(commands).ConfigureAwait(false); + } + } + + private async Task OnSlashCommandExecutedAsync(SocketSlashCommand command) + { + try + { + // The data might come from the database, which can take longer than Discord waits for an answer. + await command.DeferAsync().ConfigureAwait(false); + var argument = command.Data.Options.FirstOrDefault()?.Value as string; + var answer = await this._commands.ExecuteAsync(command.Data.Name, argument).ConfigureAwait(false); + await command.FollowupAsync(embed: ToDiscordEmbed(answer), allowedMentions: AllowedMentions.None).ConfigureAwait(false); + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error when answering the Discord command {command}.", command.Data.Name); + } + } + + private async Task RunStatusLoopAsync(CancellationToken cancellationToken) + { + var interval = this._settings.StatusUpdateInterval > TimeSpan.Zero ? this._settings.StatusUpdateInterval : TimeSpan.FromMinutes(1); + using var timer = new PeriodicTimer(interval); + try + { + do + { + await this.UpdateStatusAsync().ConfigureAwait(false); + } + while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)); + } + catch (OperationCanceledException) + { + // stopped + } + } + + private async Task UpdateStatusAsync() + { + if (this._client is not { } client) + { + return; + } + + try + { + var servers = this._data.GetGameServers(); + await client.SetCustomStatusAsync(this._statusFormatter.CreatePresence(servers)).ConfigureAwait(false); + + if (this._settings.StatusChannelId is { } channelId + && await this.GetMessageChannelAsync(channelId).ConfigureAwait(false) is { } channel) + { + var embed = ToDiscordEmbed(this._statusFormatter.CreateStatus(servers, DateTime.UtcNow)); + if (await this.FindStatusMessageAsync(client, channel).ConfigureAwait(false) is { } message) + { + await message.ModifyAsync(properties => properties.Embed = embed).ConfigureAwait(false); + } + else + { + var newMessage = await channel.SendMessageAsync(embed: embed, allowedMentions: AllowedMentions.None).ConfigureAwait(false); + this._statusMessageId = newMessage.Id; + } + } + } + catch (Exception ex) + { + this._logger.LogWarning(ex, "The status of the Discord bot couldn't be updated."); + } + } + + /// + /// Finds the status message: the last status message of the bot in the status channel. So the bot keeps + /// using the same message after a restart, without storing anything. + /// + private async Task FindStatusMessageAsync(DiscordSocketClient client, IMessageChannel channel) + { + if (this._statusMessageId is { } messageId + && await channel.GetMessageAsync(messageId).ConfigureAwait(false) is IUserMessage knownMessage + && IsStatusMessage(knownMessage)) + { + return knownMessage; + } + + // Other messages of the bot, e.g. game events when the status channel is also used for them, are left alone. + var messages = await channel.GetMessagesAsync(50).FlattenAsync().ConfigureAwait(false); + var message = messages.OfType().FirstOrDefault(IsStatusMessage); + this._statusMessageId = message?.Id; + return message; + + bool IsStatusMessage(IUserMessage m) => m.Author.Id == client.CurrentUser.Id && m.Embeds.FirstOrDefault()?.Title == this._statusFormatter.StatusTitle; + } + + private async Task GetMessageChannelAsync(ulong channelId) + { + if (this._client is not { } client) + { + return null; + } + + return client.GetChannel(channelId) as IMessageChannel + ?? await client.GetChannelAsync(channelId).ConfigureAwait(false) as IMessageChannel; + } + + private Task OnLogAsync(LogMessage message) + { + var level = message.Severity switch + { + LogSeverity.Critical => LogLevel.Critical, + LogSeverity.Error => LogLevel.Error, + LogSeverity.Warning => LogLevel.Warning, + LogSeverity.Info => LogLevel.Information, + _ => LogLevel.Debug, + }; + this._logger.Log(level, message.Exception, "{source}: {message}", message.Source, message.Message); + + // With an invalid token, the library would try to connect again and again. + if (message.Exception is global::Discord.Net.HttpException { HttpCode: System.Net.HttpStatusCode.Unauthorized }) + { + this._logger.LogError("Discord rejected the token of the bot, so the bot is stopped. Please check the configured token."); + _ = Task.Run(async () => await this.ShutdownAsync().ConfigureAwait(false)); + } + + return Task.CompletedTask; + } + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/src/Discord/DiscordBotChannelSender.cs b/src/Discord/DiscordBotChannelSender.cs new file mode 100644 index 0000000000..a43711134f --- /dev/null +++ b/src/Discord/DiscordBotChannelSender.cs @@ -0,0 +1,40 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord; + +using System.Threading; +using Microsoft.Extensions.Logging; + +/// +/// Sends s to a channel through the bot, in the background. +/// +/// +/// The rate limits are handled by the Discord library of the bot. +/// +public sealed class DiscordBotChannelSender : DiscordMessageQueue +{ + private readonly ulong _channelId; + private readonly IDiscordMessenger _messenger; + + /// + /// Initializes a new instance of the class. + /// + /// The identifier of the channel. + /// The messenger of the bot. + /// The logger. + /// The maximum number of queued embeds. + public DiscordBotChannelSender(ulong channelId, IDiscordMessenger messenger, ILogger logger, int capacity) + : base(logger, capacity) + { + this._channelId = channelId; + this._messenger = messenger; + } + + /// + protected override Task SendAsync(IReadOnlyList embeds, CancellationToken cancellationToken) + { + return this._messenger.SendAsync(this._channelId, embeds, cancellationToken); + } +} diff --git a/src/Discord/DiscordBotSettings.cs b/src/Discord/DiscordBotSettings.cs new file mode 100644 index 0000000000..5827aa95bd --- /dev/null +++ b/src/Discord/DiscordBotSettings.cs @@ -0,0 +1,44 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord; + +/// +/// The settings of the Discord bot. +/// +public class DiscordBotSettings +{ + /// + /// Gets or sets the token of the bot, from the Discord developer portal. Without token, the bot doesn't run. + /// + public string? Token { get; set; } + + /// + /// 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. + /// + 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. + /// + public ulong? StatusChannelId { get; set; } + + /// + /// Gets or sets the identifiers of the channels per , into which the bot posts the game events. + /// A category with a channel is posted by the bot instead of its webhook. + /// + public Dictionary Channels { get; set; } = new(); + + /// + /// Gets or sets the interval in which the status message and the presence of the bot are updated. + /// + public TimeSpan StatusUpdateInterval { get; set; } = TimeSpan.FromMinutes(1); + + /// + /// Gets a value indicating whether the bot is enabled. + /// + public bool IsEnabled => !string.IsNullOrWhiteSpace(this.Token); +} diff --git a/src/Discord/DiscordCommandDefinition.cs b/src/Discord/DiscordCommandDefinition.cs new file mode 100644 index 0000000000..03de59891d --- /dev/null +++ b/src/Discord/DiscordCommandDefinition.cs @@ -0,0 +1,14 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord; + +/// +/// The definition of a slash command of the Discord bot. +/// +/// The name, e.g. who. +/// The resource key of the description. +/// The name of the text option, if the command has one. +/// The resource key of the description of the option. +public sealed record DiscordCommandDefinition(string Name, string DescriptionKey, string? OptionName = null, string? OptionDescriptionKey = null); diff --git a/src/Discord/DiscordCommands.cs b/src/Discord/DiscordCommands.cs new file mode 100644 index 0000000000..866d106a45 --- /dev/null +++ b/src/Discord/DiscordCommands.cs @@ -0,0 +1,191 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord; + +using System.Globalization; +using System.Text; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.Discord.Properties; +using MUnique.OpenMU.Interfaces; + +/// +/// The slash commands of the Discord bot. They only show information and don't change anything in the game. +/// +public sealed class DiscordCommands +{ + /// + /// The color of the answers (blue). + /// + internal const int AnswerColor = 0x3498DB; + + /// + /// The number of characters which the ranking shows. + /// + /// + /// A message of Discord has a limited length, so the ranking can't be arbitrarily long. + /// + internal const int RankingSize = 10; + + private readonly IDiscordGameDataProvider _data; + private readonly CultureInfo _culture; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The provider of the data. + /// The culture of the answers. + /// The logger. + public DiscordCommands(IDiscordGameDataProvider data, CultureInfo culture, ILogger logger) + { + this._data = data; + this._culture = culture; + this._logger = logger; + } + + /// + /// Gets the definitions of the commands. + /// + public static IReadOnlyList Definitions { get; } = + [ + new("online", nameof(Resources.Command_Online_Description)), + new("who", nameof(Resources.Command_Who_Description), "character", nameof(Resources.Command_Who_CharacterOption)), + new("guild", nameof(Resources.Command_Guild_Description), "name", nameof(Resources.Command_Guild_GuildOption)), + new("events", nameof(Resources.Command_Events_Description)), + new("rank", nameof(Resources.Command_Rank_Description)), + ]; + + /// + /// Gets the culture of the answers. + /// + public CultureInfo Culture => this._culture; + + /// + /// Executes a command. + /// + /// The name of the command. + /// The value of the option of the command, if it has one. + /// The answer. + public async ValueTask ExecuteAsync(string commandName, string? argument) + { + try + { + return commandName switch + { + "online" => this.Online(), + "who" => await this.WhoAsync(argument ?? string.Empty).ConfigureAwait(false), + "guild" => await this.GuildAsync(argument ?? string.Empty).ConfigureAwait(false), + "events" => await this.EventsAsync().ConfigureAwait(false), + "rank" => await this.RankAsync().ConfigureAwait(false), + _ => this.Answer(commandName, this.Text(nameof(Resources.Command_Failed))), + }; + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error when executing the Discord command {command}.", commandName); + return this.Answer(commandName, this.Text(nameof(Resources.Command_Failed))); + } + } + + /// + /// Gets the text of a resource in the culture of the answers. + /// + /// The resource key. + /// The format arguments. + /// The text. + internal string Text(string resourceKey, params object[] args) + { + var text = Resources.ResourceManager.GetString(resourceKey, this._culture) ?? resourceKey; + return args.Length == 0 ? text : string.Format(this._culture, text, args); + } + + private DiscordEmbed Online() + { + var servers = this._data.GetGameServers(); + var lines = servers.Select(server => server.IsOnline + ? this.Text(nameof(Resources.Online_ServerLine), DiscordMessageFormatter.Escape(server.Name), server.CurrentPlayers, server.MaximumPlayers) + : this.Text(nameof(Resources.Online_ServerOffline), DiscordMessageFormatter.Escape(server.Name))); + var total = servers.Where(server => server.IsOnline).Sum(server => server.CurrentPlayers); + return this.Answer( + this.Text(nameof(Resources.Online_Title)), + string.Join('\n', lines.Append(this.Text(nameof(Resources.Online_Total), total)))); + } + + private async ValueTask WhoAsync(string name) + { + if (await this._data.GetCharacterAsync(name).ConfigureAwait(false) is not { } character) + { + return this.Answer(name, this.Text(nameof(Resources.Character_NotFound), DiscordMessageFormatter.Escape(name))); + } + + var description = new StringBuilder() + .AppendLine(this.Translate(character.ClassName)) + .AppendLine(this.GetLevels(character)) + .Append(character.OnlineServerName is { } serverName + ? this.Text(nameof(Resources.Character_OnlineOn), DiscordMessageFormatter.Escape(serverName)) + : this.Text(nameof(Resources.Character_Offline))); + return this.Answer(character.Name, description.ToString()); + } + + private async ValueTask GuildAsync(string name) + { + if (await this._data.GetGuildAsync(name).ConfigureAwait(false) is not { } guild) + { + return this.Answer(name, this.Text(nameof(Resources.Guild_NotFound), DiscordMessageFormatter.Escape(name))); + } + + var description = new StringBuilder(); + if (guild.MasterName is { } masterName) + { + description.AppendLine(this.Text(nameof(Resources.Guild_Master), DiscordMessageFormatter.Escape(masterName))); + } + + description + .AppendLine(this.Text(nameof(Resources.Guild_Members), guild.MemberCount)) + .Append(this.Text(nameof(Resources.Guild_OnlineMembers), guild.OnlineMemberNames.Count > 0 ? string.Join(", ", guild.OnlineMemberNames.Select(DiscordMessageFormatter.Escape)) : "-")); + return this.Answer(guild.Name, description.ToString()); + } + + private async ValueTask EventsAsync() + { + var events = await this._data.GetUpcomingEventsAsync(this._culture).ConfigureAwait(false); + var description = events.Count == 0 + ? this.Text(nameof(Resources.Events_None)) + : string.Join('\n', events.Select(e => $"{DiscordMessageFormatter.RelativeTime(e.StartsAtUtc)} {DiscordMessageFormatter.Escape(e.Name)}")); + return this.Answer(this.Text(nameof(Resources.Events_Title)), description); + } + + private async ValueTask RankAsync() + { + var ranking = await this._data.GetRankingAsync(RankingSize).ConfigureAwait(false); + var description = ranking.Count == 0 + ? this.Text(nameof(Resources.Rank_Empty)) + : string.Join('\n', ranking.Select((character, index) => $"{index + 1}. **{DiscordMessageFormatter.Escape(character.Name)}** ({this.Translate(character.ClassName)}) – {this.GetLevels(character)}")); + return this.Answer(this.Text(nameof(Resources.Rank_Title)), description); + } + + private string GetLevels(CharacterInfo character) + { + var parts = new List { this.Text(nameof(Resources.Character_Level), character.Level) }; + if (character.MasterLevel > 0) + { + parts.Add(this.Text(nameof(Resources.Character_MasterLevel), character.MasterLevel)); + } + + if (character.Resets > 0) + { + parts.Add(this.Text(nameof(Resources.Character_Resets), character.Resets)); + } + + return string.Join(", ", parts); + } + + private string Translate(string localizedValue) + { + return DiscordMessageFormatter.Escape(new LocalizedString(localizedValue).GetTranslation(this._culture) ?? localizedValue); + } + + private DiscordEmbed Answer(string title, string description) => new(title, description, AnswerColor, DateTime.UtcNow, null); +} diff --git a/src/Discord/DiscordGameDataProvider.cs b/src/Discord/DiscordGameDataProvider.cs new file mode 100644 index 0000000000..a6794e7cb1 --- /dev/null +++ b/src/Discord/DiscordGameDataProvider.cs @@ -0,0 +1,166 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord; + +using System.Globalization; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.PlugIns; + +/// +/// Provides the data of the game for the Discord bot, through the interfaces of the servers and the database. +/// It works in-process and with the servers in separate processes. +/// +public sealed class DiscordGameDataProvider : IDiscordGameDataProvider +{ + /// + /// The time span in which the upcoming events are shown. + /// + private static readonly TimeSpan UpcomingEventsTimeSpan = TimeSpan.FromHours(24); + + /// + /// The time for which the ranking is cached. + /// + private static readonly TimeSpan RankingCacheDuration = TimeSpan.FromMinutes(2); + + private readonly IServerProvider _serverProvider; + private readonly IPersistenceContextProvider _persistenceContextProvider; + private readonly IFriendServer _friendServer; + private readonly IGuildServer _guildServer; + private readonly PlugInManager _plugInManager; + private readonly Func _getServerTimeZone; + + private (DateTime CreatedAtUtc, int Count, IReadOnlyList Ranking)? _rankingCache; + + /// + /// Initializes a new instance of the class. + /// + /// The server provider. + /// The persistence context provider. + /// The friend server, which knows the online state of the characters. + /// The guild server. + /// The plugin manager, with the active plugins which run the events. + /// The function which gets the time zone of the server, in which the schedules of the events are defined. + public DiscordGameDataProvider( + IServerProvider serverProvider, + IPersistenceContextProvider persistenceContextProvider, + IFriendServer friendServer, + IGuildServer guildServer, + PlugInManager plugInManager, + Func getServerTimeZone) + { + this._serverProvider = serverProvider; + this._persistenceContextProvider = persistenceContextProvider; + this._friendServer = friendServer; + this._guildServer = guildServer; + this._plugInManager = plugInManager; + this._getServerTimeZone = getServerTimeZone; + } + + /// + public IReadOnlyList GetGameServers() + { + return this._serverProvider.Servers + .OfType() + .OrderBy(server => server.Id) + .Select(server => new GameServerStatus( + server.Id, + server.Description, + server.ServerState == ServerState.Started, + server.CurrentConnections, + server.MaximumConnections)) + .ToList(); + } + + /// + public async ValueTask GetCharacterAsync(string name) + { + CharacterSummary? summary; + using (var context = this.CreatePlayerContext()) + { + summary = await context.GetCharacterSummaryAsync(name, Stats.Level.Id, Stats.MasterLevel.Id, Stats.Resets.Id).ConfigureAwait(false); + } + + if (summary is null) + { + return null; + } + + var serverId = await this._friendServer.GetOnlineServerIdAsync(summary.Name).ConfigureAwait(false); + return ToInfo(summary, serverId is { } id ? this.GetServerName(id) : null); + } + + /// + public async ValueTask GetGuildAsync(string name) + { + if (await this._guildServer.GetPersistentGuildIdByNameAsync(name).ConfigureAwait(false) is not { } persistentId + || await this._guildServer.GetGuildIdAsync(persistentId).ConfigureAwait(false) is not (> 0 and var guildId) + || await this._guildServer.GetGuildAsync(guildId).ConfigureAwait(false) is not { } guild) + { + return null; + } + + var members = await this._guildServer.GetGuildListAsync(guildId).ConfigureAwait(false); + return new GuildInfo( + guild.Name ?? name, + members.FirstOrDefault(member => member.PlayerPosition == GuildPosition.GuildMaster)?.PlayerName, + members.Count, + members + .Where(member => member.ServerId != (byte)SpecialServerId.Offline && member.ServerId != (byte)SpecialServerId.Invisible) + .Select(member => member.PlayerName) + .OfType() + .ToList()); + } + + /// + public ValueTask> GetUpcomingEventsAsync(CultureInfo culture) + { + IReadOnlyList events = UpcomingEvents + .Get(this._plugInManager, DateTime.UtcNow, this._getServerTimeZone(), UpcomingEventsTimeSpan, culture) + .Select(e => new UpcomingEventInfo(e.Name, e.StartsAtUtc)) + .ToList(); + return ValueTask.FromResult(events); + } + + /// + /// + /// The query is expensive and every member of the Discord server can trigger it with /rank, + /// so the ranking is cached for a while; it changes slowly anyway. + /// + public async ValueTask> GetRankingAsync(int count) + { + var now = DateTime.UtcNow; + if (this._rankingCache is { } cache && cache.Count == count && now - cache.CreatedAtUtc < RankingCacheDuration) + { + return cache.Ranking; + } + + using var context = this.CreatePlayerContext(); + var ranking = (await context.GetCharacterRankingAsync(Stats.Level.Id, Stats.MasterLevel.Id, Stats.Resets.Id, count).ConfigureAwait(false)) + .Select(summary => ToInfo(summary, null)) + .ToList(); + this._rankingCache = (now, count, ranking); + return ranking; + } + + private static CharacterInfo ToInfo(CharacterSummary summary, string? onlineServerName) + => new(summary.Name, summary.CharacterClassName, summary.Level, summary.MasterLevel, summary.Resets, onlineServerName); + + private IPlayerContext CreatePlayerContext() + { + // The queries only read summaries, so they don't need the actual game configuration, + // which would have to be loaded completely otherwise. + return this._persistenceContextProvider.CreateNewPlayerContext(new GameConfiguration()); + } + + private string GetServerName(byte serverId) + { + return this._serverProvider.Servers.OfType().FirstOrDefault(server => server.Id == serverId)?.Description + ?? serverId.ToString(CultureInfo.InvariantCulture); + } +} diff --git a/src/Discord/DiscordMessageFormatter.cs b/src/Discord/DiscordMessageFormatter.cs index cea564059a..604598a6f9 100644 --- a/src/Discord/DiscordMessageFormatter.cs +++ b/src/Discord/DiscordMessageFormatter.cs @@ -147,7 +147,9 @@ internal static string Escape(string text) /// /// Gets the Discord markup for a time, which each Discord user sees relative to the current time in their own language, e.g. "in 5 minutes". /// - private static string RelativeTime(DateTime utc) => $""; + /// The time, in UTC. + /// The markup. + internal static string RelativeTime(DateTime utc) => $""; /// /// Gets the Discord markup for a time, which each Discord user sees as date and time in their own time zone. diff --git a/src/Discord/DiscordMessageQueue.cs b/src/Discord/DiscordMessageQueue.cs new file mode 100644 index 0000000000..c12403c376 --- /dev/null +++ b/src/Discord/DiscordMessageQueue.cs @@ -0,0 +1,145 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord; + +using System.Threading; +using System.Threading.Channels; +using Microsoft.Extensions.Logging; + +/// +/// Queues s for one Discord channel and sends them in the background. +/// +/// +/// Discord limits the number of messages per channel. So the embeds are queued, and the queued embeds +/// are combined into one message (up to ) while the previous message is sent. +/// When the queue is full, the oldest embed is dropped. +/// +public abstract class DiscordMessageQueue : IAsyncDisposable +{ + /// + /// The maximum number of embeds which Discord accepts in one message. + /// + internal const int MaximumEmbedsPerMessage = 10; + + private static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(5); + + private readonly Channel _queue; + private readonly CancellationTokenSource _cancellation = new(); + private Task? _worker; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The maximum number of queued embeds. + protected DiscordMessageQueue(ILogger logger, int capacity) + { + this.Logger = logger; + this._queue = Channel.CreateBounded( + new BoundedChannelOptions(Math.Max(capacity, 1)) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + }, + dropped => this.Logger.LogWarning("The Discord message queue is full, dropped the message {title}.", dropped.Title)); + } + + /// + /// Gets the logger. + /// + protected ILogger Logger { get; } + + /// + /// Queues the embed for sending. + /// + /// The embed. + /// , if the embed was queued; , if the queue is shutting down. + public bool Enqueue(DiscordEmbed embed) + { + // The worker is started lazily, so that derived classes are fully initialized when it runs. + if (this._worker is null) + { + lock (this._queue) + { + this._worker ??= Task.Run(this.RunAsync); + } + } + + return this._queue.Writer.TryWrite(embed.Limit()); + } + + /// + public async ValueTask DisposeAsync() + { + // The queued messages are still sent, but the shutdown doesn't wait forever. + this._queue.Writer.TryComplete(); + try + { + if (this._worker is { } worker) + { + await worker.WaitAsync(ShutdownTimeout).ConfigureAwait(false); + } + } + catch (TimeoutException) + { + this.Logger.LogWarning("Not all Discord messages could be sent before the shutdown."); + } + finally + { + await this._cancellation.CancelAsync().ConfigureAwait(false); + this._cancellation.Dispose(); + } + } + + /// + /// Sends one message with the specified embeds. + /// + /// The embeds; at least one and at most . + /// The cancellation token. + /// The task. + protected abstract Task SendAsync(IReadOnlyList embeds, CancellationToken cancellationToken); + + private async Task RunAsync() + { + var reader = this._queue.Reader; + var cancellationToken = this._cancellation.Token; + DiscordEmbed? pending = null; + try + { + while (pending is not null || await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) + { + // Discord limits the number of embeds and the length of their texts per message. + var batch = new List(MaximumEmbedsPerMessage); + var totalLength = 0; + while (batch.Count < MaximumEmbedsPerMessage && (pending ?? (reader.TryRead(out var next) ? next : null)) is { } embed) + { + if (batch.Count > 0 && totalLength + embed.TextLength > DiscordEmbed.MaximumTotalLength) + { + pending = embed; + break; + } + + pending = null; + batch.Add(embed); + totalLength += embed.TextLength; + } + + try + { + await this.SendAsync(batch, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // One faulty message must not stop the whole queue. + this.Logger.LogError(ex, "Unexpected error when sending {count} message(s) to Discord; they are dropped.", batch.Count); + } + } + } + catch (OperationCanceledException) + { + // shutting down + } + } +} diff --git a/src/Discord/DiscordWebhookNotifier.cs b/src/Discord/DiscordNotifier.cs similarity index 75% rename from src/Discord/DiscordWebhookNotifier.cs rename to src/Discord/DiscordNotifier.cs index 53a8215e73..2b1bbbdc60 100644 --- a/src/Discord/DiscordWebhookNotifier.cs +++ b/src/Discord/DiscordNotifier.cs @@ -1,57 +1,68 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.Discord; using System.Collections.Concurrent; -using System.Globalization; using System.Net.Http; using Microsoft.Extensions.Logging; using MUnique.OpenMU.Interfaces; /// -/// Posts s as messages to Discord channels, through webhooks. +/// Posts s as messages to Discord channels, through webhooks or the bot. /// -public sealed class DiscordWebhookNotifier : IGameEventListener, IAsyncDisposable +public sealed class DiscordNotifier : IGameEventListener, IAsyncDisposable { /// /// The time after which the remembered mini game openings are forgotten. /// private static readonly TimeSpan AnnouncedOpeningRetention = TimeSpan.FromHours(1); - private readonly DiscordWebhookSettings _settings; + private readonly DiscordSettings _settings; private readonly DiscordMessageFormatter _formatter; - private readonly IReadOnlyDictionary _senders; - private readonly ILogger _logger; + private readonly IReadOnlyDictionary _senders; + private readonly ILogger _logger; private readonly ConcurrentDictionary<(byte ServerId, string MiniGameType, DateTime EnterEndsAt), DateTime> _announcedOpenings = new(); /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The settings. - /// The HTTP client. + /// The HTTP client for the webhooks. + /// The messenger of the bot, if the bot posts into channels. /// The function which gets the name of a game server by its identifier. /// The function which gets the name of a guild by its persistent identifier. /// The logger factory. /// The time provider. - public DiscordWebhookNotifier( - DiscordWebhookSettings settings, + public DiscordNotifier( + DiscordSettings settings, HttpClient httpClient, + IDiscordMessenger? botMessenger, Func getServerName, Func> getGuildName, ILoggerFactory loggerFactory, TimeProvider? timeProvider = null) { this._settings = settings; - this._logger = loggerFactory.CreateLogger(); - this._formatter = new DiscordMessageFormatter(GetCulture(settings.Language), getServerName, getGuildName); + 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); + } + } - var senders = new Dictionary(); var senderLogger = loggerFactory.CreateLogger(); foreach (var (category, url) in settings.Webhooks) { - if (string.IsNullOrWhiteSpace(url)) + // A category which the bot posts doesn't need the webhook. + if (string.IsNullOrWhiteSpace(url) || senders.ContainsKey(category)) { continue; } @@ -90,18 +101,6 @@ public async ValueTask DisposeAsync() } } - private static CultureInfo GetCulture(string language) - { - try - { - return CultureInfo.GetCultureInfo(language); - } - catch (CultureNotFoundException) - { - return CultureInfo.InvariantCulture; - } - } - 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 new file mode 100644 index 0000000000..b13dd8991b --- /dev/null +++ b/src/Discord/DiscordServiceCollectionExtensions.cs @@ -0,0 +1,68 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord; + +using System.Net.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.PlugIns; + +/// +/// Extensions to add the Discord integration to the services of a host. +/// +/// +/// The host has to provide the , , +/// , and . +/// +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. + /// + /// The services to which the Discord integration is added. + /// The settings. + /// The function which gets the time zone of the server, in which the schedules of the events are defined. + /// The same services, for chaining. + public static IServiceCollection AddDiscord(this IServiceCollection services, DiscordSettings settings, Func getServerTimeZone) + { + var culture = settings.GetCulture(); + if (settings.Bot.IsEnabled) + { + services + .AddSingleton(provider => new DiscordGameDataProvider( + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService(), + getServerTimeZone)) + .AddSingleton(provider => new DiscordCommands(provider.GetRequiredService(), culture, provider.GetRequiredService>())) + .AddSingleton(new DiscordStatusFormatter(culture)) + .AddSingleton(provider => new DiscordBot( + settings.Bot, + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService>())) + .AddHostedService(provider => provider.GetRequiredService()); + } + + if (settings.IsNotificationEnabled) + { + services.AddSingleton(provider => new DiscordNotifier( + settings, + DiscordHttpClient.Create(), + settings.Bot.IsEnabled ? provider.GetRequiredService() : null, + serverId => provider.GetRequiredService().Servers.OfType().FirstOrDefault(server => server.Id == serverId)?.Description, + guildId => provider.GetRequiredService().GetPersistentGuildNameAsync(guildId), + provider.GetRequiredService())); + } + + return services; + } +} diff --git a/src/Discord/DiscordWebhookSettings.cs b/src/Discord/DiscordSettings.cs similarity index 51% rename from src/Discord/DiscordWebhookSettings.cs rename to src/Discord/DiscordSettings.cs index daf1241783..22aea23967 100644 --- a/src/Discord/DiscordWebhookSettings.cs +++ b/src/Discord/DiscordSettings.cs @@ -1,17 +1,19 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.Discord; +using System.Globalization; + /// -/// The settings of the Discord webhook notifications. +/// The settings of the Discord integration. /// /// -/// The webhook URLs are secrets: everybody who knows them can post into the channel. +/// The webhook URLs and the bot token are secrets: everybody who knows them can post into the channels. /// So they are part of the application configuration (e.g. environment variables), not of the game configuration in the database. /// -public class DiscordWebhookSettings +public class DiscordSettings { /// /// The name of the configuration section. @@ -20,7 +22,7 @@ public class DiscordWebhookSettings /// /// Gets or sets the webhook URLs per . - /// A category without URL isn't posted. + /// A category without URL isn't posted, unless the bot posts it, see . /// public Dictionary Webhooks { get; set; } = new(); @@ -37,13 +39,35 @@ public class DiscordWebhookSettings public List EventAnnouncingServerIds { get; set; } = new(); /// - /// Gets or sets the maximum number of messages per webhook which wait to be sent. + /// Gets or sets the maximum number of messages per channel which wait to be sent. /// When there are more, the oldest ones are dropped. /// public int MaximumQueuedMessages { get; set; } = 100; /// - /// Gets a value indicating whether at least one webhook is configured. + /// Gets or sets the settings of the bot. + /// + public DiscordBotSettings Bot { get; set; } = new(); + + /// + /// 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); + + /// + /// Gets the culture of the ; or the invariant culture, if the language is unknown. /// - public bool IsEnabled => this.Webhooks.Values.Any(url => !string.IsNullOrWhiteSpace(url)); + /// The culture. + public CultureInfo GetCulture() + { + try + { + return CultureInfo.GetCultureInfo(this.Language); + } + catch (CultureNotFoundException) + { + return CultureInfo.InvariantCulture; + } + } } diff --git a/src/Discord/DiscordStatusFormatter.cs b/src/Discord/DiscordStatusFormatter.cs new file mode 100644 index 0000000000..1cec93638d --- /dev/null +++ b/src/Discord/DiscordStatusFormatter.cs @@ -0,0 +1,73 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord; + +using System.Globalization; +using MUnique.OpenMU.Discord.Properties; + +/// +/// Formats the status of the game servers, for the status message and the presence of the bot. +/// +public sealed class DiscordStatusFormatter +{ + /// + /// The color of the status message, when at least one game server is online (green). + /// + internal const int OnlineColor = 0x2ECC71; + + /// + /// The color of the status message, when no game server is online (red). + /// + internal const int OfflineColor = 0xE74C3C; + + private readonly CultureInfo _culture; + + /// + /// Initializes a new instance of the class. + /// + /// The culture of the texts. + public DiscordStatusFormatter(CultureInfo culture) + { + this._culture = culture; + } + + /// + /// Gets the title of the status, by which the bot finds its status message again. + /// + public string StatusTitle => this.Text(nameof(Resources.Status_Title)); + + /// + /// Creates the status message. + /// + /// The status of the game servers. + /// The current time, which is shown as time of the last update. + /// The status message. + public DiscordEmbed CreateStatus(IReadOnlyList servers, DateTime utcNow) + { + var description = servers.Count == 0 + ? this.Text(nameof(Resources.Status_NoServers)) + : string.Join('\n', servers.Select(server => server.IsOnline + ? this.Text(nameof(Resources.Status_Online), DiscordMessageFormatter.Escape(server.Name), server.CurrentPlayers, server.MaximumPlayers) + : this.Text(nameof(Resources.Status_Offline), DiscordMessageFormatter.Escape(server.Name)))); + var color = servers.Any(server => server.IsOnline) ? OnlineColor : OfflineColor; + return new DiscordEmbed(this.StatusTitle, description, color, utcNow, null); + } + + /// + /// Creates the text of the presence of the bot. + /// + /// The status of the game servers. + /// The text. + public string CreatePresence(IReadOnlyList servers) + { + return this.Text(nameof(Resources.Presence), servers.Where(server => server.IsOnline).Sum(server => server.CurrentPlayers)); + } + + private string Text(string resourceKey, params object[] args) + { + var text = Resources.ResourceManager.GetString(resourceKey, this._culture) ?? resourceKey; + return string.Format(this._culture, text, args); + } +} diff --git a/src/Discord/DiscordWebhookSender.cs b/src/Discord/DiscordWebhookSender.cs index 7f88173667..cf8592c52c 100644 --- a/src/Discord/DiscordWebhookSender.cs +++ b/src/Discord/DiscordWebhookSender.cs @@ -11,30 +11,17 @@ namespace MUnique.OpenMU.Discord; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; -using System.Threading.Channels; using Microsoft.Extensions.Logging; /// /// Sends s to one Discord webhook, in the background. /// /// -/// -/// Discord limits the number of messages per webhook (about 30 per minute). So the embeds are queued, -/// and the queued embeds are combined into one message (up to ) -/// while waiting for the rate limit. When the queue is full, the oldest embed is dropped. -/// -/// /// The rate limit is respected by evaluating the rate limit headers of the responses and, -/// when it was hit anyway, the Retry-After header. -/// +/// when it was hit anyway, the delay which Discord returns. /// -public sealed class DiscordWebhookSender : IAsyncDisposable +public sealed class DiscordWebhookSender : DiscordMessageQueue { - /// - /// The maximum number of embeds which Discord accepts in one message. - /// - internal const int MaximumEmbedsPerMessage = 10; - /// /// The maximum number of attempts to send a message, when Discord isn't available or returns an error. /// @@ -46,15 +33,9 @@ public sealed class DiscordWebhookSender : IAsyncDisposable DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; - private static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(5); - private readonly Uri _webhookUrl; private readonly HttpClient _httpClient; - private readonly ILogger _logger; private readonly TimeProvider _timeProvider; - private readonly Channel _queue; - private readonly CancellationTokenSource _cancellation = new(); - private readonly Task _worker; /// /// Initializes a new instance of the class. @@ -65,127 +46,18 @@ public sealed class DiscordWebhookSender : IAsyncDisposable /// The maximum number of queued embeds. /// The time provider, which is used to wait for the rate limits. public DiscordWebhookSender(Uri webhookUrl, HttpClient httpClient, ILogger logger, int capacity, TimeProvider? timeProvider = null) + : base(logger, capacity) { this._webhookUrl = webhookUrl; this._httpClient = httpClient; - this._logger = logger; this._timeProvider = timeProvider ?? TimeProvider.System; - this._queue = Channel.CreateBounded( - new BoundedChannelOptions(Math.Max(capacity, 1)) - { - FullMode = BoundedChannelFullMode.DropOldest, - SingleReader = true, - }, - dropped => this._logger.LogWarning("The Discord message queue is full, dropped the message {title}.", dropped.Title)); - this._worker = Task.Run(this.RunAsync); } - /// - /// Queues the embed for sending. - /// - /// The embed. - /// , if the embed was queued; , if the sender is shutting down. - public bool Enqueue(DiscordEmbed embed) => this._queue.Writer.TryWrite(embed.Limit()); - /// - public async ValueTask DisposeAsync() - { - // The queued messages are still sent, but the shutdown doesn't wait forever. - this._queue.Writer.TryComplete(); - try - { - await this._worker.WaitAsync(ShutdownTimeout).ConfigureAwait(false); - } - catch (TimeoutException) - { - this._logger.LogWarning("Not all Discord messages could be sent before the shutdown."); - } - finally - { - await this._cancellation.CancelAsync().ConfigureAwait(false); - this._cancellation.Dispose(); - } - } - - private static TimeSpan? GetDelay(HttpResponseMessage response, string headerName) - { - if (response.Headers.TryGetValues(headerName, out var values) - && double.TryParse(values.FirstOrDefault(), NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds)) - { - return TimeSpan.FromSeconds(Math.Max(seconds, 0)); - } - - return null; - } - - /// - /// Gets the delay after which a rate limited request can be retried. - /// Discord sends it in seconds, as decimal number in the body and rounded up in the Retry-After header. - /// - private static async Task GetRetryDelayAsync(HttpResponseMessage response, CancellationToken cancellationToken) - { - try - { - var body = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); - if (body?.RetryAfter is { } seconds) - { - return TimeSpan.FromSeconds(Math.Max(seconds, 0)); - } - } - catch (JsonException) - { - // fall back to the header - } - - return response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1); - } - - private async Task RunAsync() - { - var reader = this._queue.Reader; - var cancellationToken = this._cancellation.Token; - DiscordEmbed? pending = null; - try - { - while (pending is not null || await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) - { - // Discord limits the number of embeds and the length of their texts per message. - var batch = new List(MaximumEmbedsPerMessage); - var totalLength = 0; - while (batch.Count < MaximumEmbedsPerMessage && (pending ?? (reader.TryRead(out var next) ? next : null)) is { } embed) - { - if (batch.Count > 0 && totalLength + embed.TextLength > DiscordEmbed.MaximumTotalLength) - { - pending = embed; - break; - } - - pending = null; - batch.Add(embed); - totalLength += embed.TextLength; - } - - try - { - await this.SendAsync(batch, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - // One faulty message must not stop the whole queue. - this._logger.LogError(ex, "Unexpected error when sending {count} message(s) to Discord; they are dropped.", batch.Count); - } - } - } - catch (OperationCanceledException) - { - // shutting down - } - } - - private async Task SendAsync(IReadOnlyList batch, CancellationToken cancellationToken) + protected override async Task SendAsync(IReadOnlyList embeds, CancellationToken cancellationToken) { var message = new WebhookMessage( - batch.Select(embed => new WebhookEmbed( + embeds.Select(embed => new WebhookEmbed( embed.Title, embed.Description, embed.Color, @@ -214,43 +86,76 @@ private async Task SendAsync(IReadOnlyList batch, CancellationToke if (response.StatusCode == HttpStatusCode.TooManyRequests) { delay = await GetRetryDelayAsync(response, cancellationToken).ConfigureAwait(false); - this._logger.LogDebug("Discord rate limit hit, retrying after {delay}.", delay); + this.Logger.LogDebug("Discord rate limit hit, retrying after {delay}.", delay); } else if ((int)response.StatusCode >= 500) { delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); - this._logger.LogWarning("Discord returned {statusCode}, retrying after {delay}.", response.StatusCode, delay); + this.Logger.LogWarning("Discord returned {statusCode}, retrying after {delay}.", response.StatusCode, delay); } else if (response.StatusCode == HttpStatusCode.BadRequest) { // The URL is fine, but Discord didn't accept the content. Retrying wouldn't help. var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - this._logger.LogError("Discord rejected the content of {count} message(s): {response}", batch.Count, body); + this.Logger.LogError("Discord rejected the content of {count} message(s): {response}", embeds.Count, body); return; } else { // E.g. the webhook was deleted. Retrying wouldn't help. - this._logger.LogError("Discord rejected the message with {statusCode}. Is the webhook URL valid?", response.StatusCode); + this.Logger.LogError("Discord rejected the message with {statusCode}. Is the webhook URL valid?", response.StatusCode); return; } } catch (HttpRequestException ex) { delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); - this._logger.LogWarning(ex, "Discord isn't reachable, retrying after {delay}.", delay); + this.Logger.LogWarning(ex, "Discord isn't reachable, retrying after {delay}.", delay); } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { // The request timed out, see DiscordHttpClient.Timeout. delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); - this._logger.LogWarning(ex, "Discord didn't answer in time, retrying after {delay}.", delay); + this.Logger.LogWarning(ex, "Discord didn't answer in time, retrying after {delay}.", delay); } await Task.Delay(delay, this._timeProvider, cancellationToken).ConfigureAwait(false); } - this._logger.LogError("Couldn't send {count} message(s) to Discord after {attempts} attempts; they are dropped.", batch.Count, MaximumAttempts); + this.Logger.LogError("Couldn't send {count} message(s) to Discord after {attempts} attempts; they are dropped.", embeds.Count, MaximumAttempts); + } + + private static TimeSpan? GetDelay(HttpResponseMessage response, string headerName) + { + if (response.Headers.TryGetValues(headerName, out var values) + && double.TryParse(values.FirstOrDefault(), NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds)) + { + return TimeSpan.FromSeconds(Math.Max(seconds, 0)); + } + + return null; + } + + /// + /// Gets the delay after which a rate limited request can be retried. + /// Discord sends it in seconds, as decimal number in the body and rounded up in the Retry-After header. + /// + private static async Task GetRetryDelayAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + try + { + var body = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); + if (body?.RetryAfter is { } seconds) + { + return TimeSpan.FromSeconds(Math.Max(seconds, 0)); + } + } + catch (JsonException) + { + // fall back to the header + } + + return response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1); } private sealed record RateLimitResponse(double? RetryAfter); diff --git a/src/Discord/GameServerStatus.cs b/src/Discord/GameServerStatus.cs new file mode 100644 index 0000000000..3825b06b58 --- /dev/null +++ b/src/Discord/GameServerStatus.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; + +/// +/// The status of a game server. +/// +/// The identifier. +/// The name. +/// A value indicating whether the server is online. +/// The number of connected players. +/// The maximum number of players. +public sealed record GameServerStatus(int Id, string Name, bool IsOnline, int CurrentPlayers, int MaximumPlayers); diff --git a/src/Discord/GuildInfo.cs b/src/Discord/GuildInfo.cs new file mode 100644 index 0000000000..166f205085 --- /dev/null +++ b/src/Discord/GuildInfo.cs @@ -0,0 +1,14 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord; + +/// +/// Information about a guild. +/// +/// The name. +/// The name of the guild master. +/// The number of members. +/// The names of the members which are online. +public sealed record GuildInfo(string Name, string? MasterName, int MemberCount, IReadOnlyList OnlineMemberNames); diff --git a/src/Discord/IDiscordGameDataProvider.cs b/src/Discord/IDiscordGameDataProvider.cs new file mode 100644 index 0000000000..86a4f3d829 --- /dev/null +++ b/src/Discord/IDiscordGameDataProvider.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; + +using System.Globalization; + +/// +/// Provides the data of the game which the Discord bot shows. +/// +public interface IDiscordGameDataProvider +{ + /// + /// Gets the status of the game servers. + /// + /// The status of the game servers. + IReadOnlyList GetGameServers(); + + /// + /// Gets information about a character. + /// + /// The name of the character. + /// The information; or , if the character doesn't exist. + ValueTask GetCharacterAsync(string name); + + /// + /// Gets information about a guild. + /// + /// The name of the guild. + /// The information; or , if the guild doesn't exist. + ValueTask GetGuildAsync(string name); + + /// + /// Gets the events which start in the next hours. + /// + /// The culture of the names. + /// The events. + ValueTask> GetUpcomingEventsAsync(CultureInfo culture); + + /// + /// Gets the best characters. + /// + /// The maximum number of characters. + /// The best characters. + ValueTask> GetRankingAsync(int count); +} diff --git a/src/Discord/IDiscordMessenger.cs b/src/Discord/IDiscordMessenger.cs new file mode 100644 index 0000000000..27bccefa76 --- /dev/null +++ b/src/Discord/IDiscordMessenger.cs @@ -0,0 +1,23 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord; + +using System.Threading; + +/// +/// Sends messages to Discord channels, e.g. through the bot. +/// +public interface IDiscordMessenger +{ + /// + /// Sends a message with the embeds to the channel. It waits until the messenger is connected. + /// Mentions in the message don't ping anybody. + /// + /// The identifier of the channel. + /// The embeds. + /// The cancellation token. + /// The task. + Task SendAsync(ulong channelId, IReadOnlyList embeds, CancellationToken cancellationToken); +} diff --git a/src/Discord/MUnique.OpenMU.Discord.csproj b/src/Discord/MUnique.OpenMU.Discord.csproj index 25046305c2..f5a84486f4 100644 --- a/src/Discord/MUnique.OpenMU.Discord.csproj +++ b/src/Discord/MUnique.OpenMU.Discord.csproj @@ -18,11 +18,14 @@ + + + diff --git a/src/Discord/Properties/Resources.Designer.cs b/src/Discord/Properties/Resources.Designer.cs index 5b5f77dd0d..aebb685f21 100644 --- a/src/Discord/Properties/Resources.Designer.cs +++ b/src/Discord/Properties/Resources.Designer.cs @@ -132,6 +132,150 @@ public static string CastleSiegeStarted_Description { } } + /// + /// Looks up a localized string similar to Level {0}. + /// + public static string Character_Level { + get { + return ResourceManager.GetString("Character_Level", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Master level {0}. + /// + public static string Character_MasterLevel { + get { + return ResourceManager.GetString("Character_MasterLevel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to There is no character with the name **{0}**.. + /// + public static string Character_NotFound { + get { + return ResourceManager.GetString("Character_NotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Offline. + /// + public static string Character_Offline { + get { + return ResourceManager.GetString("Character_Offline", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Online on {0}. + /// + public static string Character_OnlineOn { + get { + return ResourceManager.GetString("Character_OnlineOn", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Resets {0}. + /// + public static string Character_Resets { + get { + return ResourceManager.GetString("Character_Resets", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Shows the events of the next 24 hours.. + /// + public static string Command_Events_Description { + get { + return ResourceManager.GetString("Command_Events_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sorry, that didn't work. Please try again later.. + /// + public static string Command_Failed { + get { + return ResourceManager.GetString("Command_Failed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Shows information about a guild.. + /// + public static string Command_Guild_Description { + get { + return ResourceManager.GetString("Command_Guild_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The name of the guild.. + /// + public static string Command_Guild_GuildOption { + get { + return ResourceManager.GetString("Command_Guild_GuildOption", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Shows how many players are online.. + /// + public static string Command_Online_Description { + get { + return ResourceManager.GetString("Command_Online_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Shows the best characters.. + /// + public static string Command_Rank_Description { + get { + return ResourceManager.GetString("Command_Rank_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The name of the character.. + /// + public static string Command_Who_CharacterOption { + get { + return ResourceManager.GetString("Command_Who_CharacterOption", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Shows information about a character.. + /// + public static string Command_Who_Description { + get { + return ResourceManager.GetString("Command_Who_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to There are no events in the next 24 hours.. + /// + public static string Events_None { + get { + return ResourceManager.GetString("Events_None", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Upcoming events. + /// + public static string Events_Title { + get { + return ResourceManager.GetString("Events_Title", resourceCulture); + } + } + /// /// Looks up a localized string similar to Notice. /// @@ -141,6 +285,42 @@ public static string GlobalNotice_Title { } } + /// + /// Looks up a localized string similar to Guild master: {0}. + /// + public static string Guild_Master { + get { + return ResourceManager.GetString("Guild_Master", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Members: {0}. + /// + public static string Guild_Members { + get { + return ResourceManager.GetString("Guild_Members", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to There is no guild with the name **{0}**.. + /// + public static string Guild_NotFound { + get { + return ResourceManager.GetString("Guild_NotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Online: {0}. + /// + public static string Guild_OnlineMembers { + get { + return ResourceManager.GetString("Guild_OnlineMembers", resourceCulture); + } + } + /// /// Looks up a localized string similar to The invasion is over.. /// @@ -249,6 +429,69 @@ public static string MiniGameWon_Description { } } + /// + /// Looks up a localized string similar to {0}: **{1}** / {2}. + /// + public static string Online_ServerLine { + get { + return ResourceManager.GetString("Online_ServerLine", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0}: offline. + /// + public static string Online_ServerOffline { + get { + return ResourceManager.GetString("Online_ServerOffline", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Online players. + /// + public static string Online_Title { + get { + return ResourceManager.GetString("Online_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Total: **{0}** players. + /// + public static string Online_Total { + get { + return ResourceManager.GetString("Online_Total", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} players online. + /// + public static string Presence { + get { + return ResourceManager.GetString("Presence", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to There are no characters yet.. + /// + public static string Rank_Empty { + get { + return ResourceManager.GetString("Rank_Empty", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Ranking. + /// + public static string Rank_Title { + get { + return ResourceManager.GetString("Rank_Title", resourceCulture); + } + } + /// /// Looks up a localized string similar to Server: {0}. /// @@ -257,5 +500,41 @@ public static string ServerFooter { return ResourceManager.GetString("ServerFooter", resourceCulture); } } + + /// + /// Looks up a localized string similar to No game server is running.. + /// + public static string Status_NoServers { + get { + return ResourceManager.GetString("Status_NoServers", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to 🔴 **{0}**: offline. + /// + public static string Status_Offline { + get { + return ResourceManager.GetString("Status_Offline", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to 🟢 **{0}**: {1} / {2} players. + /// + public static string Status_Online { + get { + return ResourceManager.GetString("Status_Online", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Server status. + /// + public static string Status_Title { + get { + return ResourceManager.GetString("Status_Title", resourceCulture); + } + } } } diff --git a/src/Discord/Properties/Resources.de.resx b/src/Discord/Properties/Resources.de.resx index 780ee59d9a..f6c25f1dce 100644 --- a/src/Discord/Properties/Resources.de.resx +++ b/src/Discord/Properties/Resources.de.resx @@ -122,9 +122,69 @@ Die Schlacht um die Burg hat begonnen! Sie endet {0}. + + Level {0} + + + Meisterlevel {0} + + + Es gibt keinen Charakter mit dem Namen **{0}**. + + + Offline + + + Online auf {0} + + + Resets {0} + + + Zeigt die Ereignisse der nächsten 24 Stunden. + + + Das hat leider nicht funktioniert. Bitte versuche es später noch einmal. + + + Zeigt Informationen über eine Gilde. + + + Der Name der Gilde. + + + Zeigt, wie viele Spieler online sind. + + + Zeigt die besten Charaktere. + + + Der Name des Charakters. + + + Zeigt Informationen über einen Charakter. + + + In den nächsten 24 Stunden gibt es keine Ereignisse. + + + Kommende Ereignisse + Ankündigung + + Gildenmeister: {0} + + + Mitglieder: {0} + + + Es gibt keine Gilde mit dem Namen **{0}**. + + + Online: {0} + Die Invasion ist vorbei. @@ -161,7 +221,40 @@ **{0}** hat {1} gewonnen! + + {0}: **{1}** / {2} + + + {0}: offline + + + Spieler online + + + Gesamt: **{0}** Spieler + + + {0} Spieler online + + + Es gibt noch keine Charaktere. + + + Rangliste + Server: {0} + + Es läuft kein Spielserver. + + + 🔴 **{0}**: offline + + + 🟢 **{0}**: {1} / {2} Spieler + + + Serverstatus + diff --git a/src/Discord/Properties/Resources.resx b/src/Discord/Properties/Resources.resx index 39678406b7..b5389fc4be 100644 --- a/src/Discord/Properties/Resources.resx +++ b/src/Discord/Properties/Resources.resx @@ -122,9 +122,69 @@ The battle for the castle started! It ends {0}. + + Level {0} + + + Master level {0} + + + There is no character with the name **{0}**. + + + Offline + + + Online on {0} + + + Resets {0} + + + Shows the events of the next 24 hours. + + + Sorry, that didn't work. Please try again later. + + + Shows information about a guild. + + + The name of the guild. + + + Shows how many players are online. + + + Shows the best characters. + + + The name of the character. + + + Shows information about a character. + + + There are no events in the next 24 hours. + + + Upcoming events + Notice + + Guild master: {0} + + + Members: {0} + + + There is no guild with the name **{0}**. + + + Online: {0} + The invasion is over. @@ -161,7 +221,40 @@ **{0}** won {1}! + + {0}: **{1}** / {2} + + + {0}: offline + + + Online players + + + Total: **{0}** players + + + {0} players online + + + There are no characters yet. + + + Ranking + Server: {0} + + No game server is running. + + + 🔴 **{0}**: offline + + + 🟢 **{0}**: {1} / {2} players + + + Server status + diff --git a/src/Discord/UpcomingEventInfo.cs b/src/Discord/UpcomingEventInfo.cs new file mode 100644 index 0000000000..487d8ef015 --- /dev/null +++ b/src/Discord/UpcomingEventInfo.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; + +/// +/// An event which starts soon. +/// +/// The name. +/// The start time, in UTC. +public sealed record UpcomingEventInfo(string Name, DateTime StartsAtUtc); diff --git a/src/FriendServer/FriendServer.cs b/src/FriendServer/FriendServer.cs index 41fc862fb7..7efed3be6d 100644 --- a/src/FriendServer/FriendServer.cs +++ b/src/FriendServer/FriendServer.cs @@ -84,6 +84,15 @@ public async ValueTask FriendRequestAsync(string playerName, string friend return friendIsNew && saveSuccess; } + /// + public ValueTask GetOnlineServerIdAsync(string characterName) + { + return ValueTask.FromResult( + this.OnlineFriends.TryGetValue(characterName, out var onlineFriend) && !onlineFriend.IsInvisibleOrOffline + ? (byte?)onlineFriend.ServerId + : null); + } + /// public async ValueTask IsFriendAsync(string characterName, string friendName) { diff --git a/src/GameLogic/PlugIns/PeriodicTasks/IScheduledPlugIn.cs b/src/GameLogic/PlugIns/PeriodicTasks/IScheduledPlugIn.cs new file mode 100644 index 0000000000..9a9a918fbe --- /dev/null +++ b/src/GameLogic/PlugIns/PeriodicTasks/IScheduledPlugIn.cs @@ -0,0 +1,19 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; + +/// +/// A plugin which runs according to a schedule, e.g. an event like Blood Castle. +/// +public interface IScheduledPlugIn +{ + /// + /// Gets the next start time. + /// + /// The current time, in UTC. + /// The time zone of the server, in which the schedule is defined. + /// The next start time, in UTC; or , if there is none. + DateTime? GetNextStartUtc(DateTime utcNow, TimeZoneInfo serverTimeZone); +} diff --git a/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskBasePlugIn.cs b/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskBasePlugIn.cs index fa2e3b4875..f187d71ea9 100644 --- a/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskBasePlugIn.cs +++ b/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskBasePlugIn.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -15,7 +15,7 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; /// /// Configuration type. /// State type. -public abstract class PeriodicTaskBasePlugIn : IPeriodicTaskPlugIn, ISupportCustomConfiguration +public abstract class PeriodicTaskBasePlugIn : IPeriodicTaskPlugIn, ISupportCustomConfiguration, IScheduledPlugIn where TConfiguration : PeriodicTaskConfiguration where TState : PeriodicTaskGameServerState { @@ -28,6 +28,12 @@ public abstract class PeriodicTaskBasePlugIn : IPeriodic /// public TConfiguration? Configuration { get; set; } + /// + public DateTime? GetNextStartUtc(DateTime utcNow, TimeZoneInfo serverTimeZone) + { + return this.Configuration?.GetNextStartUtc(utcNow, serverTimeZone); + } + /// /// Forces the task to start on the next start check of the game server which /// owns this plug-in instance: every game server runs its own instance, so a diff --git a/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskConfiguration.cs b/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskConfiguration.cs index 82d7ce3bf0..23d75f8edb 100644 --- a/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskConfiguration.cs +++ b/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskConfiguration.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -104,4 +104,31 @@ public virtual bool IsItTimeToStart(TimeZoneInfo serverTimeZone) // For example, p = 00:00. Check that time between 00:00:00 and 00:00:05 return this.Timetable.Any(p => p.IsBetween(earlier, nowTime)); } + + /// + /// Gets the next start time of the task, according to the . + /// + /// The current time, in UTC. + /// The time zone of the server, in which the times of the are defined. + /// The next start time, in UTC; or , if the timetable is empty. + public DateTime? GetNextStartUtc(DateTime utcNow, TimeZoneInfo serverTimeZone) + { + var today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(utcNow, serverTimeZone)); + DateTime? result = null; + + // Today, the remaining times of the timetable can still come; otherwise the first one of tomorrow. + foreach (var date in new[] { today, today.AddDays(1) }) + { + foreach (var time in this.Timetable) + { + var startUtc = TimeZoneInfo.ConvertTimeToUtc(date.ToDateTime(time, DateTimeKind.Unspecified), serverTimeZone); + if (startUtc > utcNow && (result is null || startUtc < result)) + { + result = startUtc; + } + } + } + + return result; + } } \ No newline at end of file diff --git a/src/GameLogic/PlugIns/PeriodicTasks/UpcomingEvents.cs b/src/GameLogic/PlugIns/PeriodicTasks/UpcomingEvents.cs new file mode 100644 index 0000000000..0fe3b149c8 --- /dev/null +++ b/src/GameLogic/PlugIns/PeriodicTasks/UpcomingEvents.cs @@ -0,0 +1,54 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; + +using System.Globalization; +using System.Reflection; +using System.Resources; +using MUnique.OpenMU.PlugIns; + +/// +/// Determines the upcoming events, like mini games and invasions, from the schedules of the active plugins. +/// +public static class UpcomingEvents +{ + /// + /// Gets the events which start within the specified time span, ordered by their start time. + /// + /// The plugin manager with the active plugins. + /// The current time, in UTC. + /// The time zone of the server, in which the schedules are defined. + /// The time span from now, in which the events have to start. + /// The culture of the names. + /// The names and start times of the events. + public static IReadOnlyList<(string Name, DateTime StartsAtUtc)> Get( + PlugInManager plugInManager, + DateTime utcNow, + TimeZoneInfo serverTimeZone, + TimeSpan timeSpan, + CultureInfo culture) + { + return plugInManager.GetActivePlugInsOf() + .OfType() + .Select(plugIn => (Name: GetName(plugIn.GetType(), culture), StartsAtUtc: plugIn.GetNextStartUtc(utcNow, serverTimeZone))) + .Where(e => e.StartsAtUtc is { } start && start - utcNow <= timeSpan) + .Select(e => (e.Name, e.StartsAtUtc!.Value)) + .OrderBy(e => e.Value) + .ToList(); + } + + private static string GetName(Type plugInType, CultureInfo culture) + { + var display = plugInType.GetCustomAttribute(); + if (display?.Name is { } name + && display.ResourceType?.GetProperty(nameof(PlugInResources.ResourceManager), BindingFlags.Public | BindingFlags.Static)?.GetValue(null) is ResourceManager resourceManager + && resourceManager.GetString(name, culture) is { } localizedName) + { + return localizedName; + } + + return display?.GetName() ?? plugInType.Name; + } +} diff --git a/src/Interfaces/IFriendServer.cs b/src/Interfaces/IFriendServer.cs index 6c976f795d..9af4c53083 100644 --- a/src/Interfaces/IFriendServer.cs +++ b/src/Interfaces/IFriendServer.cs @@ -106,4 +106,14 @@ public interface IFriendServer /// The room number. /// The success of the invitation. ValueTask InviteFriendToChatRoomAsync(string selectedCharacterName, string friendName, ushort roomNumber); + + /// + /// Gets the identifier of the game server on which the character is online. + /// + /// The name of the character. + /// + /// The identifier of the game server; or , if the character is offline + /// or invisible (which looks like offline to other players). + /// + ValueTask GetOnlineServerIdAsync(string characterName); } \ No newline at end of file diff --git a/src/Interfaces/ServerType.cs b/src/Interfaces/ServerType.cs index 25a7afeb77..d69592462a 100644 --- a/src/Interfaces/ServerType.cs +++ b/src/Interfaces/ServerType.cs @@ -28,4 +28,9 @@ public enum ServerType /// A chat server. /// ChatServer = 3, + + /// + /// The Discord bot. + /// + Discord = 4, } \ No newline at end of file diff --git a/src/MUnique.OpenMU.sln b/src/MUnique.OpenMU.sln index 951ab4bd9d..2b49069b83 100644 --- a/src/MUnique.OpenMU.sln +++ b/src/MUnique.OpenMU.sln @@ -139,6 +139,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MUnique.OpenMU.Dapr.Common" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MUnique.OpenMU.AdminPanel.Host", "Dapr\AdminPanel.Host\MUnique.OpenMU.AdminPanel.Host.csproj", "{92E2F054-1AEA-4038-A837-0F943DF0EBB1}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MUnique.OpenMU.Discord.Host", "Dapr\Discord.Host\MUnique.OpenMU.Discord.Host.csproj", "{AF9FB703-8911-4B17-806C-458B7339D2F1}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MUnique.OpenMU.Web.Map", "Web\Map\MUnique.OpenMU.Web.Map.csproj", "{0FF7079E-B864-4FDA-9329-337CC13D0C30}" EndProject Project("{E53339B2-1760-4266-BCC7-CA923CBCF16C}") = "docker-compose", "..\deploy\distributed\docker-compose.dcproj", "{0DFECD55-7AA2-4263-B6FD-EC1C4E6A59B3}" @@ -364,6 +366,10 @@ Global {92E2F054-1AEA-4038-A837-0F943DF0EBB1}.Debug|Any CPU.Build.0 = Debug|Any CPU {92E2F054-1AEA-4038-A837-0F943DF0EBB1}.Release|Any CPU.ActiveCfg = Release|Any CPU {92E2F054-1AEA-4038-A837-0F943DF0EBB1}.Release|Any CPU.Build.0 = Release|Any CPU + {AF9FB703-8911-4B17-806C-458B7339D2F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AF9FB703-8911-4B17-806C-458B7339D2F1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AF9FB703-8911-4B17-806C-458B7339D2F1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AF9FB703-8911-4B17-806C-458B7339D2F1}.Release|Any CPU.Build.0 = Release|Any CPU {0FF7079E-B864-4FDA-9329-337CC13D0C30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0FF7079E-B864-4FDA-9329-337CC13D0C30}.Debug|Any CPU.Build.0 = Debug|Any CPU {0FF7079E-B864-4FDA-9329-337CC13D0C30}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -460,6 +466,7 @@ Global {0F99599F-3298-43A4-96F7-61D61031413E} = {C154AA0C-4424-4D33-B6AF-39B2108EE7C4} {93714610-E242-450A-9CD9-F2E24DB632B0} = {C154AA0C-4424-4D33-B6AF-39B2108EE7C4} {92E2F054-1AEA-4038-A837-0F943DF0EBB1} = {C154AA0C-4424-4D33-B6AF-39B2108EE7C4} + {AF9FB703-8911-4B17-806C-458B7339D2F1} = {C154AA0C-4424-4D33-B6AF-39B2108EE7C4} {0FF7079E-B864-4FDA-9329-337CC13D0C30} = {820453DE-DA7A-454C-A077-BED8F0FA9764} {0DFECD55-7AA2-4263-B6FD-EC1C4E6A59B3} = {C154AA0C-4424-4D33-B6AF-39B2108EE7C4} {565BED68-B303-4A3D-A8C0-0088D2A50C46} = {BA9EABE2-F32F-446D-A8AA-56866A6B6CFD} diff --git a/src/Persistence/CharacterSummary.cs b/src/Persistence/CharacterSummary.cs new file mode 100644 index 0000000000..ec8d2872ee --- /dev/null +++ b/src/Persistence/CharacterSummary.cs @@ -0,0 +1,15 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence; + +/// +/// A summary of a character, e.g. for the ranking. +/// +/// The name of the character. +/// The name of the class of the character. +/// The level. +/// The master level. +/// The number of resets. +public sealed record CharacterSummary(string Name, string CharacterClassName, int Level, int MasterLevel, int Resets); diff --git a/src/Persistence/EntityFramework/CharacterRankingRow.cs b/src/Persistence/EntityFramework/CharacterRankingRow.cs new file mode 100644 index 0000000000..55db8d21a0 --- /dev/null +++ b/src/Persistence/EntityFramework/CharacterRankingRow.cs @@ -0,0 +1,17 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.EntityFramework; + +using MUnique.OpenMU.Interfaces; + +/// +/// A row of the query of the character ranking. +/// +/// The name of the character. +/// The name of the character class. +/// The level. +/// The master level. +/// The resets. +internal sealed record CharacterRankingRow(string Name, LocalizedString ClassName, float Level, float MasterLevel, float Resets); diff --git a/src/Persistence/EntityFramework/PlayerContext.cs b/src/Persistence/EntityFramework/PlayerContext.cs index 4c94fd3f94..123f8fd75e 100644 --- a/src/Persistence/EntityFramework/PlayerContext.cs +++ b/src/Persistence/EntityFramework/PlayerContext.cs @@ -261,6 +261,46 @@ public async ValueTask CanSaveLetterAsync(Interfaces.LetterHeader letterHe } } + /// + public async ValueTask> GetCharacterRankingAsync( + Guid levelAttributeId, + Guid masterLevelAttributeId, + Guid resetsAttributeId, + int count, + CancellationToken cancellationToken = default) + { + using var l = await this.LockAsync(cancellationToken).ConfigureAwait(false); + using (this.RepositoryProvider.ContextStack.UseContext(this)) + { + var entries = await CreateCharacterRankingQuery(this.Context.Set(), levelAttributeId, masterLevelAttributeId, resetsAttributeId, count) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return entries + .Select(entry => new CharacterSummary(entry.Name, entry.ClassName, (int)entry.Level, (int)entry.MasterLevel, (int)entry.Resets)) + .ToList(); + } + } + + /// + public async ValueTask GetCharacterSummaryAsync( + string characterName, + Guid levelAttributeId, + Guid masterLevelAttributeId, + Guid resetsAttributeId, + CancellationToken cancellationToken = default) + { + using var l = await this.LockAsync(cancellationToken).ConfigureAwait(false); + using (this.RepositoryProvider.ContextStack.UseContext(this)) + { + var entry = await CreateCharacterSummaryQuery(this.Context.Set().Where(character => character.Name == characterName), levelAttributeId, masterLevelAttributeId, resetsAttributeId) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + return entry is null ? null : new CharacterSummary(entry.Name, entry.ClassName, (int)entry.Level, (int)entry.MasterLevel, (int)entry.Resets); + } + } + /// public async ValueTask GetGensAbuseAsync( Guid killerId, @@ -275,4 +315,57 @@ public async ValueTask CanSaveLetterAsync(Interfaces.LetterHeader letterHe .ConfigureAwait(false); } } + + /// + /// Creates the query of the character ranking. + /// + /// The accounts. + /// The identifier of the attribute definition of the level. + /// The identifier of the attribute definition of the master level. + /// The identifier of the attribute definition of the resets. + /// The maximum number of entries. + /// The query. + internal static IQueryable CreateCharacterRankingQuery( + IQueryable accounts, + Guid levelAttributeId, + Guid masterLevelAttributeId, + Guid resetsAttributeId, + int count) + { + // EF can't order by the members of the projected record, so the ordering happens before the projection. + var characters = accounts + .Where(account => !account.IsBot && !account.IsTemplate) + .SelectMany(account => account.RawCharacters) + .Where(character => character.CharacterStatus == DataModel.Entities.CharacterStatus.Normal) + .OrderByDescending(character => character.RawAttributes.Where(a => a.DefinitionId == resetsAttributeId).Select(a => a.Value).FirstOrDefault()) + .ThenByDescending(character => character.RawAttributes.Where(a => a.DefinitionId == masterLevelAttributeId).Select(a => a.Value).FirstOrDefault()) + .ThenByDescending(character => character.RawAttributes.Where(a => a.DefinitionId == levelAttributeId).Select(a => a.Value).FirstOrDefault()) + .ThenByDescending(character => character.MasterExperience) + .ThenByDescending(character => character.Experience) + .Take(count); + + return CreateCharacterSummaryQuery(characters, levelAttributeId, masterLevelAttributeId, resetsAttributeId); + } + + /// + /// Creates the query which projects the characters to their summary. + /// + /// The characters. + /// The identifier of the attribute definition of the level. + /// The identifier of the attribute definition of the master level. + /// The identifier of the attribute definition of the resets. + /// The query. + internal static IQueryable CreateCharacterSummaryQuery( + IQueryable characters, + Guid levelAttributeId, + Guid masterLevelAttributeId, + Guid resetsAttributeId) + { + return characters.Select(character => new CharacterRankingRow( + character.Name, + character.RawCharacterClass!.Name, + character.RawAttributes.Where(a => a.DefinitionId == levelAttributeId).Select(a => a.Value).FirstOrDefault(), + character.RawAttributes.Where(a => a.DefinitionId == masterLevelAttributeId).Select(a => a.Value).FirstOrDefault(), + character.RawAttributes.Where(a => a.DefinitionId == resetsAttributeId).Select(a => a.Value).FirstOrDefault())); + } } diff --git a/src/Persistence/IPlayerContext.cs b/src/Persistence/IPlayerContext.cs index 6218425622..01d1a7aef6 100644 --- a/src/Persistence/IPlayerContext.cs +++ b/src/Persistence/IPlayerContext.cs @@ -182,4 +182,40 @@ ValueTask> GetLatestCashShopCoinGrantsAsync( Guid killerId, Guid victimId, CancellationToken cancellationToken = default); + + /// + /// Gets the ranking of the characters of players, ordered by resets, master level, level and experience. + /// Characters of bots, templates and game masters are not ranked. + /// + /// + /// The levels are attributes of the characters; their definitions are passed, because they are defined by the game logic. + /// + /// The identifier of the attribute definition of the level. + /// The identifier of the attribute definition of the master level. + /// The identifier of the attribute definition of the resets. + /// The maximum number of entries. + /// The cancellation token. + /// The best characters. + ValueTask> GetCharacterRankingAsync( + Guid levelAttributeId, + Guid masterLevelAttributeId, + Guid resetsAttributeId, + int count, + CancellationToken cancellationToken = default); + + /// + /// Gets the summary of a character. + /// + /// The name of the character. + /// The identifier of the attribute definition of the level. + /// The identifier of the attribute definition of the master level. + /// The identifier of the attribute definition of the resets. + /// The cancellation token. + /// The summary of the character; or , if it doesn't exist. + ValueTask GetCharacterSummaryAsync( + string characterName, + Guid levelAttributeId, + Guid masterLevelAttributeId, + Guid resetsAttributeId, + CancellationToken cancellationToken = default); } diff --git a/src/Persistence/InMemory/PlayerInMemoryContext.cs b/src/Persistence/InMemory/PlayerInMemoryContext.cs index 173dee7aab..e61577f747 100644 --- a/src/Persistence/InMemory/PlayerInMemoryContext.cs +++ b/src/Persistence/InMemory/PlayerInMemoryContext.cs @@ -167,6 +167,51 @@ public async ValueTask CanSaveLetterAsync(Interfaces.LetterHeader letterHe return members.FirstOrDefault(member => member.CharacterId == characterId); } + /// + public async ValueTask> GetCharacterRankingAsync( + Guid levelAttributeId, + Guid masterLevelAttributeId, + Guid resetsAttributeId, + int count, + CancellationToken cancellationToken = default) + { + var accounts = await this.Provider.GetRepository() + .GetAllAsync(cancellationToken) + .ConfigureAwait(false); + + return accounts + .Where(account => !account.IsBot && !account.IsTemplate) + .SelectMany(account => account.Characters) + .Where(character => character.CharacterStatus == DataModel.Entities.CharacterStatus.Normal) + .OrderByDescending(character => GetValue(character, resetsAttributeId)) + .ThenByDescending(character => GetValue(character, masterLevelAttributeId)) + .ThenByDescending(character => GetValue(character, levelAttributeId)) + .ThenByDescending(character => character.MasterExperience) + .ThenByDescending(character => character.Experience) + .Take(count) + .Select(character => ToSummary(character, levelAttributeId, masterLevelAttributeId, resetsAttributeId)) + .ToList(); + } + + /// + public async ValueTask GetCharacterSummaryAsync( + string characterName, + Guid levelAttributeId, + Guid masterLevelAttributeId, + Guid resetsAttributeId, + CancellationToken cancellationToken = default) + { + var accounts = await this.Provider.GetRepository() + .GetAllAsync(cancellationToken) + .ConfigureAwait(false); + + return accounts + .SelectMany(account => account.Characters) + .Where(character => character.Name == characterName) + .Select(character => ToSummary(character, levelAttributeId, masterLevelAttributeId, resetsAttributeId)) + .FirstOrDefault(); + } + /// public async ValueTask GetGensAbuseAsync( Guid killerId, @@ -178,4 +223,15 @@ public async ValueTask CanSaveLetterAsync(Interfaces.LetterHeader letterHe .ConfigureAwait(false); return abuses.FirstOrDefault(abuse => abuse.KillerId == killerId && abuse.VictimId == victimId); } + + private static float GetValue(DataModel.Entities.Character character, Guid attributeId) + => character.Attributes.FirstOrDefault(a => a.Definition?.Id == attributeId)?.Value ?? 0; + + private static CharacterSummary ToSummary(DataModel.Entities.Character character, Guid levelAttributeId, Guid masterLevelAttributeId, Guid resetsAttributeId) + => new( + character.Name, + (string?)character.CharacterClass?.Name ?? string.Empty, + (int)GetValue(character, levelAttributeId), + (int)GetValue(character, masterLevelAttributeId), + (int)GetValue(character, resetsAttributeId)); } diff --git a/src/Startup/DiscordServiceCollectionExtensions.cs b/src/Startup/DiscordServiceCollectionExtensions.cs deleted file mode 100644 index 5099672730..0000000000 --- a/src/Startup/DiscordServiceCollectionExtensions.cs +++ /dev/null @@ -1,42 +0,0 @@ -// -// Licensed under the MIT License. See LICENSE file in the project root for full license information. -// - -namespace MUnique.OpenMU.Startup; - -using System.Net.Http; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using MUnique.OpenMU.Discord; -using MUnique.OpenMU.Interfaces; - -/// -/// Extensions to add the Discord integration to the services. -/// -internal static class DiscordServiceCollectionExtensions -{ - /// - /// Adds the as , - /// if at least one webhook is configured in the section. - /// - /// The services to which the notifier is added. - /// The configuration, e.g. with the environment variable Discord__Webhooks__Events. - /// The game servers, to get their names. - /// The same services, for chaining. - public static IServiceCollection AddDiscordWebhookNotifier(this IServiceCollection services, IConfiguration configuration, IDictionary gameServers) - { - var settings = configuration.GetSection(DiscordWebhookSettings.SectionName).Get(); - if (settings?.IsEnabled is not true) - { - return services; - } - - return services.AddSingleton(provider => new DiscordWebhookNotifier( - settings, - DiscordHttpClient.Create(), - serverId => gameServers.Values.FirstOrDefault(server => server.Id == serverId)?.Description, - guildId => provider.GetRequiredService().GetPersistentGuildNameAsync(guildId), - provider.GetRequiredService())); - } -} diff --git a/src/Startup/Program.cs b/src/Startup/Program.cs index 291a8d6164..181c2ef744 100644 --- a/src/Startup/Program.cs +++ b/src/Startup/Program.cs @@ -18,6 +18,7 @@ namespace MUnique.OpenMU.Startup; using MUnique.OpenMU.ChatServer; using MUnique.OpenMU.ConnectServer; using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.Discord; using MUnique.OpenMU.FriendServer; using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic.TestActors; @@ -344,7 +345,7 @@ private async Task CreateHostAsync(string[] args) .AddSingleton() .AddSingleton() .AddSingleton() - .AddDiscordWebhookNotifier(builder.Configuration, this._gameServers) + .AddDiscord(builder.Configuration.GetSection(DiscordSettings.SectionName).Get() ?? new DiscordSettings(), () => ServerTimeZone) .AddSingleton() .AddSingleton(s => s.GetService()!) .AddSingleton() @@ -397,6 +398,12 @@ private async Task CreateHostAsync(string[] args) var host = builder.Build(); + // The bot is listed with the other servers, e.g. in the admin panel. + if (host.Services.GetService() is { } discordBot) + { + this._servers.Add(discordBot); + } + // NpgsqlLoggingConfiguration.InitializeLogging(host.Services.GetRequiredService()) this._logger.Information("Host created"); diff --git a/tests/MUnique.OpenMU.Tests/CharacterRankingTest.cs b/tests/MUnique.OpenMU.Tests/CharacterRankingTest.cs new file mode 100644 index 0000000000..e56867acb0 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/CharacterRankingTest.cs @@ -0,0 +1,112 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using Microsoft.EntityFrameworkCore; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.Persistence.EntityFramework; +using MUnique.OpenMU.Persistence.InMemory; + +/// +/// Tests for the . +/// +[TestFixture] +public class CharacterRankingTest +{ + /// + /// Tests that the query of the entity framework implementation can be translated to SQL. + /// + [Test] + public void EntityFrameworkQueryIsTranslatable() + { + using var context = new AccountContext(); + + var sql = PlayerContext.CreateCharacterRankingQuery(context.Set(), Stats.Level.Id, Stats.MasterLevel.Id, Stats.Resets.Id, 10).ToQueryString(); + + Assert.That(sql, Does.Contain("ORDER BY")); + } + + /// + /// Tests that the characters are ranked by resets, master level and level, + /// and that bots and game masters are not ranked. + /// + [Test] + public async Task CharactersAreRankedAsync() + { + var contextProvider = new InMemoryPersistenceContextProvider(); + using (var context = contextProvider.CreateNewPlayerContext(new GameConfiguration())) + { + var characterClass = context.CreateNew(); + characterClass.Name = "Blade Knight"; + AddAccount(context, characterClass, isBot: false, ("Normal", 400, 0, 0, CharacterStatus.Normal), ("Master", 400, 50, 0, CharacterStatus.Normal)); + AddAccount(context, characterClass, isBot: false, ("Reset", 100, 0, 1, CharacterStatus.Normal), ("GameMaster", 400, 200, 5, CharacterStatus.GameMaster)); + AddAccount(context, characterClass, isBot: true, ("Bot", 400, 200, 5, CharacterStatus.Normal)); + await context.SaveChangesAsync().ConfigureAwait(false); + } + + using var queryContext = contextProvider.CreateNewPlayerContext(new GameConfiguration()); + var ranking = await queryContext.GetCharacterRankingAsync(Stats.Level.Id, Stats.MasterLevel.Id, Stats.Resets.Id, 10).ConfigureAwait(false); + + Assert.That(ranking.Select(entry => entry.Name), Is.EqualTo(new[] { "Reset", "Master", "Normal" })); + Assert.That(ranking[1], Is.EqualTo(new CharacterSummary("Master", "Blade Knight", 400, 50, 0))); + } + + /// + /// Tests that the summary of a character is found by its name. + /// + [Test] + public async Task CharacterSummaryIsFoundAsync() + { + var contextProvider = new InMemoryPersistenceContextProvider(); + using (var context = contextProvider.CreateNewPlayerContext(new GameConfiguration())) + { + var characterClass = context.CreateNew(); + characterClass.Name = "Dark Wizard"; + AddAccount(context, characterClass, isBot: false, ("Wizard", 250, 0, 2, CharacterStatus.Normal)); + await context.SaveChangesAsync().ConfigureAwait(false); + } + + using var queryContext = contextProvider.CreateNewPlayerContext(new GameConfiguration()); + + Assert.That( + await queryContext.GetCharacterSummaryAsync("Wizard", Stats.Level.Id, Stats.MasterLevel.Id, Stats.Resets.Id).ConfigureAwait(false), + Is.EqualTo(new CharacterSummary("Wizard", "Dark Wizard", 250, 0, 2))); + Assert.That(await queryContext.GetCharacterSummaryAsync("Unknown", Stats.Level.Id, Stats.MasterLevel.Id, Stats.Resets.Id).ConfigureAwait(false), Is.Null); + } + + /// + /// Tests that the summary query of the entity framework implementation can be translated to SQL. + /// + [Test] + public void EntityFrameworkSummaryQueryIsTranslatable() + { + using var context = new AccountContext(); + + var sql = PlayerContext.CreateCharacterSummaryQuery(context.Set().Where(c => c.Name == "Wizard"), Stats.Level.Id, Stats.MasterLevel.Id, Stats.Resets.Id).ToQueryString(); + + Assert.That(sql, Does.Contain("WHERE")); + } + + private static void AddAccount(IContext context, CharacterClass characterClass, bool isBot, params (string Name, int Level, int MasterLevel, int Resets, CharacterStatus Status)[] characters) + { + var account = context.CreateNew(); + account.LoginName = Guid.NewGuid().ToString("N")[..10]; + account.IsBot = isBot; + foreach (var (name, level, masterLevel, resets, status) in characters) + { + var character = context.CreateNew(); + character.Name = name; + character.CharacterClass = characterClass; + character.CharacterStatus = status; + character.Attributes.Add(new Persistence.BasicModel.StatAttribute(Stats.Level, level)); + character.Attributes.Add(new Persistence.BasicModel.StatAttribute(Stats.MasterLevel, masterLevel)); + character.Attributes.Add(new Persistence.BasicModel.StatAttribute(Stats.Resets, resets)); + account.Characters.Add(character); + } + } +} diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordCommandsTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordCommandsTest.cs new file mode 100644 index 0000000000..60a58cd307 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordCommandsTest.cs @@ -0,0 +1,135 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Discord; + +using System.Globalization; +using Microsoft.Extensions.Logging.Abstractions; +using MUnique.OpenMU.Discord; + +/// +/// Tests for the . +/// +[TestFixture] +public class DiscordCommandsTest +{ + private FakeGameDataProvider _data = null!; + + /// + /// Sets up the data. + /// + [SetUp] + public void SetUp() + { + this._data = new FakeGameDataProvider(); + this._data.Servers.Add(new GameServerStatus(0, "Server 1", true, 12, 100)); + this._data.Servers.Add(new GameServerStatus(1, "Server 2", false, 0, 100)); + this._data.Characters.Add(new CharacterInfo("Hero", "Blade Knight||de=Klingenritter", 400, 120, 2, "Server 1")); + this._data.Characters.Add(new CharacterInfo("Novice", "Dark Wizard", 50, 0, 0, null)); + this._data.Guilds.Add(new GuildInfo("Legends", "Hero", 12, new[] { "Hero", "Elf" })); + } + + /// + /// Tests the online players of each server and in total. + /// + [Test] + public async Task OnlineShowsServersAndTotalAsync() + { + var answer = await CreateCommands("en", this._data).ExecuteAsync("online", null).ConfigureAwait(false); + + Assert.That(answer.Description, Is.EqualTo("Server 1: **12** / 100\nServer 2: offline\nTotal: **12** players")); + } + + /// + /// Tests the information about an online character, in German. + /// + [Test] + public async Task WhoShowsCharacterAsync() + { + var answer = await CreateCommands("de", this._data).ExecuteAsync("who", "Hero").ConfigureAwait(false); + + Assert.That(answer.Title, Is.EqualTo("Hero")); + Assert.That(answer.Description, Is.EqualTo("Klingenritter\nLevel 400, Meisterlevel 120, Resets 2\nOnline auf Server 1")); + } + + /// + /// Tests the answer for an unknown character. + /// + [Test] + public async Task WhoOfUnknownCharacterAsync() + { + var answer = await CreateCommands("en", this._data).ExecuteAsync("who", "Nobody_").ConfigureAwait(false); + + Assert.That(answer.Description, Is.EqualTo(@"There is no character with the name **Nobody\_**.")); + } + + /// + /// Tests the information about a guild. + /// + [Test] + public async Task GuildShowsMasterAndOnlineMembersAsync() + { + var answer = await CreateCommands("en", this._data).ExecuteAsync("guild", "Legends").ConfigureAwait(false); + + Assert.That(answer.Description, Is.EqualTo("Guild master: Hero\nMembers: 12\nOnline: Hero, Elf")); + } + + /// + /// Tests the upcoming events, with the start as Discord time markup. + /// + [Test] + public async Task EventsShowsStartAsync() + { + var start = new DateTime(2026, 10, 8, 20, 0, 0, DateTimeKind.Utc); + this._data.Events.Add(new UpcomingEventInfo("Blood Castle event", start)); + + var answer = await CreateCommands("en", this._data).ExecuteAsync("events", null).ConfigureAwait(false); + + Assert.That(answer.Description, Is.EqualTo($" Blood Castle event")); + } + + /// + /// Tests the ranking. + /// + [Test] + public async Task RankShowsCharactersInOrderAsync() + { + var answer = await CreateCommands("en", this._data).ExecuteAsync("rank", null).ConfigureAwait(false); + + Assert.That(answer.Description, Is.EqualTo("1. **Hero** (Blade Knight) – Level 400, Master level 120, Resets 2\n2. **Novice** (Dark Wizard) – Level 50")); + } + + /// + /// Tests that an error results in a friendly answer. + /// + [Test] + public async Task ErrorResultsInFriendlyAnswerAsync() + { + this._data.Fails = true; + + var answer = await CreateCommands("en", this._data).ExecuteAsync("who", "Hero").ConfigureAwait(false); + + Assert.That(answer.Description, Is.EqualTo("Sorry, that didn't work. Please try again later.")); + } + + /// + /// Tests that all descriptions of the commands are defined in the resources, because Discord requires them. + /// + [Test] + public void AllCommandDescriptionsExist() + { + var commands = CreateCommands("en", this._data); + foreach (var definition in DiscordCommands.Definitions) + { + Assert.That(commands.Text(definition.DescriptionKey), Is.Not.EqualTo(definition.DescriptionKey)); + if (definition.OptionDescriptionKey is { } optionKey) + { + Assert.That(commands.Text(optionKey), Is.Not.EqualTo(optionKey)); + } + } + } + + private static DiscordCommands CreateCommands(string language, IDiscordGameDataProvider data) + => new(data, CultureInfo.GetCultureInfo(language), NullLogger.Instance); +} diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordGameDataProviderTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordGameDataProviderTest.cs new file mode 100644 index 0000000000..5e8be541bf --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordGameDataProviderTest.cs @@ -0,0 +1,166 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Discord; + +using System.Collections.Immutable; +using System.Globalization; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.Discord; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Persistence.InMemory; +using MUnique.OpenMU.PlugIns; + +/// +/// Tests for the . +/// +[TestFixture] +public class DiscordGameDataProviderTest +{ + private const uint GuildRuntimeId = 7; + + private readonly Guid _guildId = Guid.NewGuid(); + private InMemoryPersistenceContextProvider _persistence = null!; + private Mock _friendServer = null!; + private Mock _guildServer = null!; + private PlugInManager _plugInManager = null!; + private DiscordGameDataProvider _provider = null!; + + /// + /// Sets up the provider with in-memory data and mocked servers. + /// + [SetUp] + public async Task SetUpAsync() + { + var gameServer = new Mock(); + gameServer.SetupGet(s => s.Id).Returns(3); + gameServer.SetupGet(s => s.Description).Returns("Server 4"); + gameServer.SetupGet(s => s.ServerState).Returns(ServerState.Started); + gameServer.SetupGet(s => s.CurrentConnections).Returns(5); + gameServer.SetupGet(s => s.MaximumConnections).Returns(100); + var serverProvider = new Mock(); + serverProvider.SetupGet(p => p.Servers).Returns(new List { gameServer.Object, Mock.Of() }); + + this._friendServer = new Mock(); + this._friendServer.Setup(s => s.GetOnlineServerIdAsync("Hero")).ReturnsAsync((byte?)3); + + this._guildServer = new Mock(); + this._guildServer.Setup(s => s.GetPersistentGuildIdByNameAsync("Legends")).ReturnsAsync(this._guildId); + this._guildServer.Setup(s => s.GetGuildIdAsync(this._guildId)).ReturnsAsync(GuildRuntimeId); + this._guildServer.Setup(s => s.GetGuildAsync(GuildRuntimeId)).ReturnsAsync(new Interfaces.Guild { Name = "Legends" }); + this._guildServer.Setup(s => s.GetGuildListAsync(GuildRuntimeId)).ReturnsAsync(ImmutableList.Create( + new GuildListEntry { PlayerName = "Hero", PlayerPosition = GuildPosition.GuildMaster, ServerId = 3 }, + new GuildListEntry { PlayerName = "Elf", PlayerPosition = GuildPosition.NormalMember, ServerId = (byte)SpecialServerId.Offline }, + new GuildListEntry { PlayerName = "Ghost", PlayerPosition = GuildPosition.NormalMember, ServerId = (byte)SpecialServerId.Invisible }) as IImmutableList); + + this._plugInManager = new PlugInManager([], NullLoggerFactory.Instance, null, null); + + var persistence = this._persistence = new InMemoryPersistenceContextProvider(); + using (var context = persistence.CreateNewPlayerContext(new GameConfiguration())) + { + var characterClass = context.CreateNew(); + characterClass.Name = "Blade Knight"; + var account = context.CreateNew(); + account.LoginName = "hero"; + var character = context.CreateNew(); + character.Name = "Hero"; + character.CharacterClass = characterClass; + character.Attributes.Add(new Persistence.BasicModel.StatAttribute(Stats.Level, 400)); + account.Characters.Add(character); + await context.SaveChangesAsync().ConfigureAwait(false); + } + + this._provider = new DiscordGameDataProvider(serverProvider.Object, persistence, this._friendServer.Object, this._guildServer.Object, this._plugInManager, () => TimeZoneInfo.Utc); + } + + /// + /// Tests that only the game servers are listed. + /// + [Test] + public void GameServersAreListed() + { + Assert.That(this._provider.GetGameServers(), Is.EqualTo(new[] { new GameServerStatus(3, "Server 4", true, 5, 100) })); + } + + /// + /// Tests that a character is found with its online state. + /// + [Test] + public async Task CharacterIsFoundWithOnlineServerAsync() + { + var character = await this._provider.GetCharacterAsync("Hero").ConfigureAwait(false); + + Assert.That(character, Is.EqualTo(new CharacterInfo("Hero", "Blade Knight", 400, 0, 0, "Server 4"))); + Assert.That(await this._provider.GetCharacterAsync("Nobody").ConfigureAwait(false), Is.Null); + } + + /// + /// Tests that a guild is found with its master and the members which are visibly online. + /// + [Test] + public async Task GuildIsFoundWithOnlineMembersAsync() + { + var guild = await this._provider.GetGuildAsync("Legends").ConfigureAwait(false); + + Assert.That(guild?.MasterName, Is.EqualTo("Hero")); + Assert.That(guild?.MemberCount, Is.EqualTo(3)); + Assert.That(guild?.OnlineMemberNames, Is.EqualTo(new[] { "Hero" })); + Assert.That(await this._provider.GetGuildAsync("Unknown").ConfigureAwait(false), Is.Null); + } + + /// + /// Tests that the upcoming events come from the schedules of the active plugins. + /// + [Test] + public async Task UpcomingEventsComeFromThePlugInsAsync() + { + var nextHour = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(1)); + this._plugInManager.RegisterPlugInAtPlugInPoint(new GoldenInvasionPlugIn { Configuration = new PeriodicInvasionConfiguration { Timetable = { nextHour } } }); + + var events = await this._provider.GetUpcomingEventsAsync(CultureInfo.InvariantCulture).ConfigureAwait(false); + + Assert.That(events.Select(e => e.Name), Is.EqualTo(new[] { "Golden Invasion" })); + } + + /// + /// Tests that the ranking comes from the database. + /// + [Test] + public async Task RankingComesFromTheDatabaseAsync() + { + var ranking = await this._provider.GetRankingAsync(10).ConfigureAwait(false); + + Assert.That(ranking.Select(c => c.Name), Is.EqualTo(new[] { "Hero" })); + } + + /// + /// Tests that the ranking is cached, because every member of the Discord server can request it. + /// + [Test] + public async Task RankingIsCachedAsync() + { + var first = await this._provider.GetRankingAsync(10).ConfigureAwait(false); + using (var context = this._persistence.CreateNewPlayerContext(new GameConfiguration())) + { + var account = context.CreateNew(); + account.LoginName = "newcomer"; + var character = context.CreateNew(); + character.Name = "Newcomer"; + character.CharacterClass = context.CreateNew(); + character.Attributes.Add(new Persistence.BasicModel.StatAttribute(Stats.Level, 1)); + account.Characters.Add(character); + await context.SaveChangesAsync().ConfigureAwait(false); + } + + var second = await this._provider.GetRankingAsync(10).ConfigureAwait(false); + + Assert.That(second, Is.SameAs(first)); + } +} diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordWebhookNotifierTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordNotifierTest.cs similarity index 71% rename from tests/MUnique.OpenMU.Tests/Discord/DiscordWebhookNotifierTest.cs rename to tests/MUnique.OpenMU.Tests/Discord/DiscordNotifierTest.cs index 0d6e6d690f..912315fc9c 100644 --- a/tests/MUnique.OpenMU.Tests/Discord/DiscordWebhookNotifierTest.cs +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordNotifierTest.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -10,10 +10,10 @@ namespace MUnique.OpenMU.Tests.Discord; using MUnique.OpenMU.Interfaces; /// -/// Tests for the . +/// Tests for the . /// [TestFixture] -public class DiscordWebhookNotifierTest +public class DiscordNotifierTest { /// /// Tests that a mini game which opens several levels at the same time is announced just once. @@ -22,7 +22,7 @@ public class DiscordWebhookNotifierTest public async Task MiniGameOpeningIsAnnouncedOnceAsync() { var handler = new RecordingHttpMessageHandler(); - await using var notifier = CreateNotifier(handler, new DiscordWebhookSettings()); + await using var notifier = CreateNotifier(handler, new DiscordSettings()); var closesAt = DateTime.UtcNow.AddMinutes(5); for (byte level = 1; level <= 7; level++) @@ -42,7 +42,7 @@ public async Task MiniGameOpeningIsAnnouncedOnceAsync() public async Task EventsOfOtherServersAreNotAnnouncedAsync() { var handler = new RecordingHttpMessageHandler(); - await using var notifier = CreateNotifier(handler, new DiscordWebhookSettings { EventAnnouncingServerIds = { 0 } }); + await using var notifier = CreateNotifier(handler, new DiscordSettings { EventAnnouncingServerIds = { 0 } }); await notifier.OnGameEventAsync(new InvasionStartedEvent(1, DateTime.UtcNow, Guid.Empty, "Golden Invasion", new[] { "Lorencia" })).ConfigureAwait(false); await notifier.OnGameEventAsync(new InvasionStartedEvent(0, DateTime.UtcNow, Guid.Empty, "Golden Invasion", new[] { "Devias" })).ConfigureAwait(false); @@ -60,7 +60,7 @@ public async Task EventsOfOtherServersAreNotAnnouncedAsync() public async Task CategoryWithoutWebhookIsNotPostedAsync() { var handler = new RecordingHttpMessageHandler(); - await using var notifier = CreateNotifier(handler, new DiscordWebhookSettings()); + await using var notifier = CreateNotifier(handler, new DiscordSettings()); await notifier.OnGameEventAsync(new GlobalNoticeEvent(0, DateTime.UtcNow, "GM", "Hello")).ConfigureAwait(false); await notifier.OnGameEventAsync(new BossKilledEvent(0, DateTime.UtcNow, "Hero", "Kundun", "Kalima 7")).ConfigureAwait(false); @@ -71,14 +71,37 @@ public async Task CategoryWithoutWebhookIsNotPostedAsync() Assert.That(titles, Is.EqualTo(new[] { "Boss defeated" })); } - private static DiscordWebhookNotifier CreateNotifier(RecordingHttpMessageHandler handler, DiscordWebhookSettings settings) + /// + /// Tests that a category which has a channel of the bot is posted by the bot instead of the webhook. + /// + [Test] + 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; + await using (var notifier = CreateNotifier(handler, settings, messenger)) + { + await notifier.OnGameEventAsync(new BossKilledEvent(0, DateTime.UtcNow, "Hero", "Kundun", "Kalima 7")).ConfigureAwait(false); + } + + Assert.That(messenger.Messages, Has.Count.EqualTo(1)); + Assert.That(messenger.Messages[0].ChannelId, Is.EqualTo(42)); + Assert.That(messenger.Messages[0].Embeds[0].Title, Is.EqualTo("Boss defeated")); + Assert.That(handler.Requests, 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. settings.Webhooks[DiscordChannelCategory.Events] = "https://discord.example/api/webhooks/1/events"; settings.Webhooks[DiscordChannelCategory.WorldNews] = "https://discord.example/api/webhooks/2/news"; - return new DiscordWebhookNotifier( + return new DiscordNotifier( settings, new HttpClient(handler), + botMessenger, _ => null, _ => ValueTask.FromResult(null), NullLoggerFactory.Instance); diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordServiceCollectionExtensionsTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordServiceCollectionExtensionsTest.cs new file mode 100644 index 0000000000..9a3bedf0d6 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordServiceCollectionExtensionsTest.cs @@ -0,0 +1,81 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Discord; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using MUnique.OpenMU.Discord; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.Persistence.InMemory; +using MUnique.OpenMU.PlugIns; + +/// +/// Tests for the . +/// +[TestFixture] +public class DiscordServiceCollectionExtensionsTest +{ + /// + /// Tests that nothing is added without configuration. + /// + [Test] + public void NothingIsAddedWithoutConfiguration() + { + using var provider = CreateServices(new DiscordSettings()).BuildServiceProvider(); + + Assert.That(provider.GetService(), Is.Null); + Assert.That(provider.GetServices(), Is.Empty); + } + + /// + /// Tests that only the notifier is added, when only webhooks are configured. + /// + [Test] + public async Task NotifierIsAddedForWebhooksAsync() + { + var settings = new DiscordSettings(); + settings.Webhooks[DiscordChannelCategory.Events] = "https://discord.example/api/webhooks/1/token"; + await using var provider = CreateServices(settings).BuildServiceProvider(); + + Assert.That(provider.GetService(), Is.Null); + Assert.That(provider.GetServices().Single(), Is.InstanceOf()); + } + + /// + /// Tests that the bot is added as hosted service, and that the notifier posts through it. + /// + [Test] + public async Task BotIsAddedWithTokenAsync() + { + var settings = new DiscordSettings(); + settings.Bot.Token = "token"; + settings.Bot.Channels[DiscordChannelCategory.Events] = 42; + await using var provider = CreateServices(settings).BuildServiceProvider(); + + var bot = provider.GetRequiredService(); + Assert.That(provider.GetServices(), Does.Contain(bot)); + Assert.That(provider.GetServices().Single(), Is.InstanceOf()); + Assert.That(bot.ServerState, Is.EqualTo(ServerState.Stopped)); + } + + private static IServiceCollection CreateServices(DiscordSettings settings) + { + var serverProvider = new Mock(); + serverProvider.SetupGet(p => p.Servers).Returns(new List()); + return new ServiceCollection() + .AddSingleton(NullLoggerFactory.Instance) + .AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)) + .AddSingleton(serverProvider.Object) + .AddSingleton(new InMemoryPersistenceContextProvider()) + .AddSingleton(Mock.Of()) + .AddSingleton(Mock.Of()) + .AddSingleton(new PlugInManager([], NullLoggerFactory.Instance, null, null)) + .AddDiscord(settings, () => TimeZoneInfo.Utc); + } +} diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordStatusFormatterTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordStatusFormatterTest.cs new file mode 100644 index 0000000000..8d00dc81e6 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordStatusFormatterTest.cs @@ -0,0 +1,56 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Discord; + +using System.Globalization; +using MUnique.OpenMU.Discord; + +/// +/// Tests for the . +/// +[TestFixture] +public class DiscordStatusFormatterTest +{ + private static readonly IReadOnlyList Servers = new[] + { + new GameServerStatus(0, "Server 1", true, 12, 100), + new GameServerStatus(1, "Server 2", true, 3, 100), + new GameServerStatus(2, "Server 3", false, 0, 100), + }; + + /// + /// Tests the status message. + /// + [Test] + public void StatusListsAllServers() + { + var status = new DiscordStatusFormatter(CultureInfo.GetCultureInfo("en")).CreateStatus(Servers, DateTime.UtcNow); + + Assert.That(status.Description, Is.EqualTo("🟢 **Server 1**: 12 / 100 players\n🟢 **Server 2**: 3 / 100 players\n🔴 **Server 3**: offline")); + Assert.That(status.Color, Is.EqualTo(DiscordStatusFormatter.OnlineColor)); + } + + /// + /// Tests the status message without any server online. + /// + [Test] + public void StatusWithoutOnlineServerIsRed() + { + var status = new DiscordStatusFormatter(CultureInfo.GetCultureInfo("en")).CreateStatus(Servers.Where(s => !s.IsOnline).ToList(), DateTime.UtcNow); + + Assert.That(status.Color, Is.EqualTo(DiscordStatusFormatter.OfflineColor)); + } + + /// + /// Tests the presence, which counts the players of the online servers. + /// + [Test] + public void PresenceCountsPlayers() + { + var presence = new DiscordStatusFormatter(CultureInfo.GetCultureInfo("de")).CreatePresence(Servers); + + Assert.That(presence, Is.EqualTo("15 Spieler online")); + } +} diff --git a/tests/MUnique.OpenMU.Tests/Discord/FakeGameDataProvider.cs b/tests/MUnique.OpenMU.Tests/Discord/FakeGameDataProvider.cs new file mode 100644 index 0000000000..e985beac04 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Discord/FakeGameDataProvider.cs @@ -0,0 +1,55 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Discord; + +using System.Globalization; +using MUnique.OpenMU.Discord; + +/// +/// A with fixed data. +/// +internal sealed class FakeGameDataProvider : IDiscordGameDataProvider +{ + /// + /// Gets the game servers. + /// + public List Servers { get; } = new(); + + /// + /// Gets the characters. + /// + public List Characters { get; } = new(); + + /// + /// Gets the guilds. + /// + public List Guilds { get; } = new(); + + /// + /// Gets the upcoming events. + /// + public List Events { get; } = new(); + + /// + /// Gets or sets a value indicating whether the provider fails. + /// + public bool Fails { get; set; } + + /// + public IReadOnlyList GetGameServers() => this.Servers; + + /// + public ValueTask GetCharacterAsync(string name) + => this.Fails ? throw new InvalidOperationException("Test") : ValueTask.FromResult(this.Characters.FirstOrDefault(c => c.Name == name)); + + /// + public ValueTask GetGuildAsync(string name) => ValueTask.FromResult(this.Guilds.FirstOrDefault(g => g.Name == name)); + + /// + public ValueTask> GetUpcomingEventsAsync(CultureInfo culture) => ValueTask.FromResult>(this.Events); + + /// + public ValueTask> GetRankingAsync(int count) => ValueTask.FromResult>(this.Characters.Take(count).ToList()); +} diff --git a/tests/MUnique.OpenMU.Tests/Discord/RecordingMessenger.cs b/tests/MUnique.OpenMU.Tests/Discord/RecordingMessenger.cs new file mode 100644 index 0000000000..11e754d021 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Discord/RecordingMessenger.cs @@ -0,0 +1,30 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Discord; + +using System.Threading; +using MUnique.OpenMU.Discord; + +/// +/// A which records the sent messages. +/// +internal sealed class RecordingMessenger : IDiscordMessenger +{ + /// + /// Gets the sent messages. + /// + public List<(ulong ChannelId, IReadOnlyList Embeds)> Messages { get; } = new(); + + /// + public Task SendAsync(ulong channelId, IReadOnlyList embeds, CancellationToken cancellationToken) + { + lock (this.Messages) + { + this.Messages.Add((channelId, embeds)); + } + + return Task.CompletedTask; + } +} diff --git a/tests/MUnique.OpenMU.Tests/FriendServerTest.cs b/tests/MUnique.OpenMU.Tests/FriendServerTest.cs index 328cbe1c7e..753a119f69 100644 --- a/tests/MUnique.OpenMU.Tests/FriendServerTest.cs +++ b/tests/MUnique.OpenMU.Tests/FriendServerTest.cs @@ -211,6 +211,25 @@ public async ValueTask TestOnlineListAsync() this._gameServer2.Verify(gs => gs.FriendOnlineStateChangedAsync(this._player2.Name, this._player1.Name, FriendServer.FriendServer.OfflineServerId), Times.AtLeastOnce); } + /// + /// Tests that the online state of a character can be queried, and that an invisible character appears offline. + /// + [Test] + public async Task OnlineServerOfCharacterIsReportedAsync() + { + Assert.That(await this._friendServer.GetOnlineServerIdAsync(this._player1.Name).ConfigureAwait(false), Is.Null); + + await this.PlayerEnteredGameAsync(this._player1.Id, this._player1.Name, 2).ConfigureAwait(false); + Assert.That(await this._friendServer.GetOnlineServerIdAsync(this._player1.Name).ConfigureAwait(false), Is.EqualTo(2)); + + await this._friendServer.SetPlayerVisibilityStateAsync(2, this._player1.Id, this._player1.Name, false).ConfigureAwait(false); + Assert.That(await this._friendServer.GetOnlineServerIdAsync(this._player1.Name).ConfigureAwait(false), Is.Null); + + await this._friendServer.SetPlayerVisibilityStateAsync(2, this._player1.Id, this._player1.Name, true).ConfigureAwait(false); + await this._friendServer.PlayerLeftGameAsync(this._player1.Id, this._player1.Name).ConfigureAwait(false); + Assert.That(await this._friendServer.GetOnlineServerIdAsync(this._player1.Name).ConfigureAwait(false), Is.Null); + } + private async ValueTask PlayerEnteredGameAsync(Guid playerId, string playerName, int serverId) { await this._friendServer.PlayerEnteredGameAsync((byte)serverId, playerId, playerName).ConfigureAwait(false); diff --git a/tests/MUnique.OpenMU.Tests/UpcomingEventsTest.cs b/tests/MUnique.OpenMU.Tests/UpcomingEventsTest.cs new file mode 100644 index 0000000000..ccf2474868 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/UpcomingEventsTest.cs @@ -0,0 +1,104 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using System.Globalization; +using Microsoft.Extensions.Logging.Abstractions; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents; +using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; +using MUnique.OpenMU.PlugIns; + +/// +/// Tests for the and . +/// +[TestFixture] +public class UpcomingEventsTest +{ + private static readonly DateTime Now = new(2026, 10, 8, 12, 30, 0, DateTimeKind.Utc); + + /// + /// Tests that the next start is the next time of the timetable today. + /// + [Test] + public void NextStartIsLaterToday() + { + var configuration = CreateConfiguration(new TimeOnly(8, 0), new TimeOnly(14, 0), new TimeOnly(20, 0)); + + Assert.That(configuration.GetNextStartUtc(Now, TimeZoneInfo.Utc), Is.EqualTo(new DateTime(2026, 10, 8, 14, 0, 0, DateTimeKind.Utc))); + } + + /// + /// Tests that the next start is the first time of the timetable tomorrow, when all times of today passed. + /// + [Test] + public void NextStartIsTomorrow() + { + var configuration = CreateConfiguration(new TimeOnly(8, 0), new TimeOnly(10, 0)); + + Assert.That(configuration.GetNextStartUtc(Now, TimeZoneInfo.Utc), Is.EqualTo(new DateTime(2026, 10, 9, 8, 0, 0, DateTimeKind.Utc))); + } + + /// + /// Tests that the times of the timetable are in the time zone of the server. + /// + [Test] + public void NextStartRespectsTheServerTimeZone() + { + var timeZone = TimeZoneInfo.CreateCustomTimeZone("UTC+2", TimeSpan.FromHours(2), "UTC+2", "UTC+2"); + var configuration = CreateConfiguration(new TimeOnly(16, 0)); + + Assert.That(configuration.GetNextStartUtc(Now, timeZone), Is.EqualTo(new DateTime(2026, 10, 8, 14, 0, 0, DateTimeKind.Utc))); + } + + /// + /// Tests that there is no next start without timetable. + /// + [Test] + public void NoNextStartWithoutTimetable() + { + Assert.That(CreateConfiguration().GetNextStartUtc(Now, TimeZoneInfo.Utc), Is.Null); + } + + /// + /// Tests that the upcoming events contain the active scheduled plugins with their names, ordered by their start. + /// + [Test] + public void UpcomingEventsAreOrderedAndNamed() + { + var plugInManager = new PlugInManager([], NullLoggerFactory.Instance, null, null); + plugInManager.RegisterPlugInAtPlugInPoint(new GoldenInvasionPlugIn { Configuration = CreateInvasionConfiguration(new TimeOnly(18, 0)) }); + plugInManager.RegisterPlugInAtPlugInPoint(new RedDragonInvasionPlugIn { Configuration = CreateInvasionConfiguration(new TimeOnly(13, 0)) }); + + var events = UpcomingEvents.Get(plugInManager, Now, TimeZoneInfo.Utc, TimeSpan.FromHours(12), CultureInfo.InvariantCulture); + + Assert.That(events.Select(e => e.Name), Is.EqualTo(new[] { "Red Dragon Invasion", "Golden Invasion" })); + Assert.That(events[0].StartsAtUtc, Is.EqualTo(new DateTime(2026, 10, 8, 13, 0, 0, DateTimeKind.Utc))); + } + + /// + /// Tests that events which start later than the requested time span aren't included. + /// + [Test] + public void EventsAfterTheTimeSpanAreExcluded() + { + var plugInManager = new PlugInManager([], NullLoggerFactory.Instance, null, null); + plugInManager.RegisterPlugInAtPlugInPoint(new GoldenInvasionPlugIn { Configuration = CreateInvasionConfiguration(new TimeOnly(18, 0)) }); + + var events = UpcomingEvents.Get(plugInManager, Now, TimeZoneInfo.Utc, TimeSpan.FromHours(1), CultureInfo.InvariantCulture); + + Assert.That(events, Is.Empty); + } + + private static PeriodicTaskConfiguration CreateConfiguration(params TimeOnly[] times) + { + return new PeriodicTaskConfiguration { Timetable = times.ToList() }; + } + + private static PeriodicInvasionConfiguration CreateInvasionConfiguration(params TimeOnly[] times) + { + return new PeriodicInvasionConfiguration { Timetable = times.ToList() }; + } +}