diff --git a/docs-website/docs/admin-panel/plugins.md b/docs-website/docs/admin-panel/plugins.md index 628fbb7be..787b4ef21 100644 --- a/docs-website/docs/admin-panel/plugins.md +++ b/docs-website/docs/admin-panel/plugins.md @@ -94,8 +94,8 @@ 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. It's disabled by default; activate it when such a system is -connected. +integration (see [Discord notifications](../server-features/discord.md)). It's +disabled by default; activate it when such a system is connected. It publishes: @@ -103,9 +103,14 @@ It publishes: * the start and end of invasions, with the invaded maps, * the state changes of the castle siege, with the owner of the castle, * excellent and ancient items which are dropped by monsters, +* the kills of boss monsters, +* characters which reach a level or master level milestone, * global notices which game masters send with the `!` chat prefix. -Each kind of event can be switched off in the plugin configuration. +Each kind of event can be switched off in the plugin configuration. There you +also define which monsters are bosses, by their monster numbers (e.g. 275 for +Kundun), and which levels and master levels are milestones (by default, level +400). ## Server announcements diff --git a/docs-website/docs/server-features/discord.md b/docs-website/docs/server-features/discord.md new file mode 100644 index 000000000..094d62dad --- /dev/null +++ b/docs-website/docs/server-features/discord.md @@ -0,0 +1,78 @@ +--- +title: Discord notifications +sidebar_label: Discord +sidebar_position: 5 +description: Post events of the game, like mini games, castle siege and rare drops, to Discord channels. +--- + +# Discord notifications + +OpenMU can post what happens in the game to the channels of your Discord server: + +| Category | What is posted | +|---|---| +| `Events` | Mini games which open (e.g. Blood Castle), winners of mini games, invasions which start and end | +| `CastleSiege` | Guild registration, the start and the end of the battle, and the new owner of the castle | +| `WorldNews` | Boss kills, excellent and ancient items which monsters dropped, characters which reach a level milestone | +| `Notices` | Global notices which game masters send with the `!` chat prefix | + +Each category is posted to its own channel through a Discord *webhook*. Categories +without webhook aren't posted. + +:::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. +::: + +## Setting it up + +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`: + + ```yaml + environment: + - Discord__Webhooks__Events=https://discord.com/api/webhooks/... + - Discord__Webhooks__CastleSiege=https://discord.com/api/webhooks/... + - Discord__Webhooks__WorldNews=https://discord.com/api/webhooks/... + - 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. + +## 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. + +| 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. | +| `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. | + +## 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. + 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. +* 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/docs-website/sidebars.js b/docs-website/sidebars.js index 51cdc843c..9d8bd0c88 100644 --- a/docs-website/sidebars.js +++ b/docs-website/sidebars.js @@ -63,7 +63,8 @@ const sidebars = { 'server-features/test-actors', 'server-features/gens', 'server-features/cash-shop', - "server-features/player-shop-currencies", + 'server-features/discord', + 'server-features/player-shop-currencies', ], }, { diff --git a/src/Discord/DiscordChannelCategory.cs b/src/Discord/DiscordChannelCategory.cs new file mode 100644 index 000000000..8e021a435 --- /dev/null +++ b/src/Discord/DiscordChannelCategory.cs @@ -0,0 +1,31 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord; + +/// +/// The categories of notifications, each of which is posted to its own Discord channel. +/// +public enum DiscordChannelCategory +{ + /// + /// Events like mini games and invasions. + /// + Events, + + /// + /// The state changes of the castle siege. + /// + CastleSiege, + + /// + /// News about players, like boss kills, rare drops and level milestones. + /// + WorldNews, + + /// + /// Notices of game masters. + /// + Notices, +} diff --git a/src/Discord/DiscordEmbed.cs b/src/Discord/DiscordEmbed.cs new file mode 100644 index 000000000..130fddf64 --- /dev/null +++ b/src/Discord/DiscordEmbed.cs @@ -0,0 +1,64 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord; + +/// +/// A message which is posted as embed to a Discord channel. +/// +/// The title. +/// The description, which may contain Discord markdown. +/// The color of the bar on the left side, as RGB value. +/// The timestamp, which is shown below the message. +/// The footer text. +public sealed record DiscordEmbed(string Title, string Description, int Color, DateTime TimestampUtc, string? Footer) +{ + /// + /// The maximum length of the title, which Discord accepts. + /// + public const int MaximumTitleLength = 256; + + /// + /// The maximum length of the description, which Discord accepts. + /// + public const int MaximumDescriptionLength = 4096; + + /// + /// The maximum length of the footer, which Discord accepts. + /// + public const int MaximumFooterLength = 2048; + + /// + /// The maximum length of the texts of all embeds of one message, which Discord accepts. + /// + public const int MaximumTotalLength = 6000; + + /// + /// Gets the length of all texts of the embed, which counts against . + /// + public int TextLength => this.Title.Length + this.Description.Length + (this.Footer?.Length ?? 0); + + /// + /// Gets the embed with texts which are cut to the lengths which Discord accepts. + /// + /// The embed, or a copy with shortened texts. + public DiscordEmbed Limit() + { + if (this.Title.Length <= MaximumTitleLength + && this.Description.Length <= MaximumDescriptionLength + && (this.Footer?.Length ?? 0) <= MaximumFooterLength) + { + return this; + } + + return this with + { + Title = Cut(this.Title, MaximumTitleLength), + Description = Cut(this.Description, MaximumDescriptionLength), + Footer = this.Footer is { } footer ? Cut(footer, MaximumFooterLength) : null, + }; + } + + private static string Cut(string text, int maximumLength) => text.Length <= maximumLength ? text : text[..(maximumLength - 1)] + "…"; +} diff --git a/src/Discord/DiscordHttpClient.cs b/src/Discord/DiscordHttpClient.cs new file mode 100644 index 000000000..3f6c296e9 --- /dev/null +++ b/src/Discord/DiscordHttpClient.cs @@ -0,0 +1,26 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord; + +using System.Net.Http; + +/// +/// Creates the HTTP client for the webhooks. +/// +public static class DiscordHttpClient +{ + /// + /// The time after which a request to Discord is aborted. + /// A sender waits for its request, so a hanging connection would stall its queue. + /// The senders retry a request which timed out. + /// + public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); + + /// + /// Creates the HTTP client. It lives as long as the process, so it isn't disposed. + /// + /// The HTTP client. + public static HttpClient Create() => new() { Timeout = Timeout }; +} diff --git a/src/Discord/DiscordMessageFormatter.cs b/src/Discord/DiscordMessageFormatter.cs new file mode 100644 index 000000000..cea564059 --- /dev/null +++ b/src/Discord/DiscordMessageFormatter.cs @@ -0,0 +1,213 @@ +// +// 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 MUnique.OpenMU.Discord.Properties; +using MUnique.OpenMU.Interfaces; + +/// +/// Formats s as Discord messages. +/// +public sealed class DiscordMessageFormatter +{ + /// + /// The color of event messages, like mini games and invasions (blue). + /// + internal const int EventColor = 0x3498DB; + + /// + /// The color of castle siege messages (orange). + /// + internal const int CastleSiegeColor = 0xE67E22; + + /// + /// The color of item drop messages (green). + /// + internal const int ItemDropColor = 0x2ECC71; + + /// + /// The color of boss kill messages (red). + /// + internal const int BossKillColor = 0xE74C3C; + + /// + /// The color of level milestone messages (yellow). + /// + internal const int MilestoneColor = 0xF1C40F; + + /// + /// The color of notices of game masters (purple). + /// + internal const int NoticeColor = 0x9B59B6; + + private const string MarkdownCharacters = @"\*_~`|>[]()#"; + + private readonly CultureInfo _culture; + private readonly Func _getServerName; + private readonly Func> _getGuildName; + + /// + /// Initializes a new instance of the class. + /// + /// The culture of the messages. + /// 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. + public DiscordMessageFormatter(CultureInfo culture, Func getServerName, Func> getGuildName) + { + this._culture = culture; + this._getServerName = getServerName; + this._getGuildName = getGuildName; + } + + /// + /// Formats the game event as Discord message. + /// + /// The game event. + /// The category and the message; or , if the event isn't posted. + public async ValueTask<(DiscordChannelCategory Category, DiscordEmbed Embed)?> FormatAsync(GameEvent gameEvent) + { + return gameEvent switch + { + MiniGameEntranceOpenedEvent e => (DiscordChannelCategory.Events, this.CreateEmbed( + e, + this.Format(nameof(Resources.MiniGameEntranceOpened_Title), this.Translate(e.MiniGameName)), + this.Format(nameof(Resources.MiniGameEntranceOpened_Description), RelativeTime(e.EnterEndsAtUtc)), + EventColor)), + MiniGameEndedEvent { WinnerName: { } winner } e => (DiscordChannelCategory.Events, this.CreateEmbed( + e, + this.Translate(e.MiniGameName), + this.Format(nameof(Resources.MiniGameWon_Description), Escape(winner), $"{this.Translate(e.MiniGameName)} {e.GameLevel}"), + EventColor)), + InvasionStartedEvent e => (DiscordChannelCategory.Events, this.CreateEmbed( + e, + e.InvasionName, + this.Format(nameof(Resources.InvasionStarted_Description), string.Join(", ", e.MapNames.Select(this.Translate))), + EventColor)), + InvasionEndedEvent e => (DiscordChannelCategory.Events, this.CreateEmbed( + e, + e.InvasionName, + this.Format(nameof(Resources.InvasionEnded_Description)), + EventColor)), + CastleSiegeStateChangedEvent e => await this.FormatCastleSiegeAsync(e).ConfigureAwait(false) is { } embed + ? (DiscordChannelCategory.CastleSiege, embed) + : null, + MonsterItemDroppedEvent e => (DiscordChannelCategory.WorldNews, this.CreateEmbed( + e, + this.Format(nameof(Resources.ItemDropped_Title)), + this.Format(nameof(Resources.ItemDropped_Description), Escape(e.KillerName), this.GetItemName(e), this.Translate(e.MonsterName), this.Translate(e.MapName)), + ItemDropColor)), + BossKilledEvent e => (DiscordChannelCategory.WorldNews, this.CreateEmbed( + e, + this.Format(nameof(Resources.BossKilled_Title)), + this.Format(nameof(Resources.BossKilled_Description), Escape(e.KillerName), this.Translate(e.MonsterName), this.Translate(e.MapName)), + BossKillColor)), + CharacterLevelMilestoneEvent e => (DiscordChannelCategory.WorldNews, this.CreateEmbed( + e, + this.Format(nameof(Resources.LevelMilestone_Title)), + this.Format( + e.IsMasterLevel ? nameof(Resources.MasterLevelMilestone_Description) : nameof(Resources.LevelMilestone_Description), + Escape(e.CharacterName), + this.Translate(e.CharacterClassName), + e.Level), + MilestoneColor)), + GlobalNoticeEvent e => (DiscordChannelCategory.Notices, this.CreateEmbed( + e, + this.Format(nameof(Resources.GlobalNotice_Title)), + Escape(e.Message), + NoticeColor)), + _ => null, + }; + } + + /// + /// Escapes the characters of the text which have a meaning in Discord markdown. + /// + /// The text. + /// The escaped text. + internal static string Escape(string text) + { + var builder = new StringBuilder(text.Length); + foreach (var character in text) + { + if (MarkdownCharacters.Contains(character)) + { + builder.Append('\\'); + } + + builder.Append(character); + } + + return builder.ToString(); + } + + /// + /// 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) => $""; + + /// + /// Gets the Discord markup for a time, which each Discord user sees as date and time in their own time zone. + /// + private static string DateAndTime(DateTime utc) => $""; + + private async ValueTask FormatCastleSiegeAsync(CastleSiegeStateChangedEvent e) + { + var title = this.Format(nameof(Resources.CastleSiege_Title)); + string? description = e.State switch + { + "RegisterGuild" => this.Format(nameof(Resources.CastleSiegeRegistration_Description), DateAndTime(e.StateEndsAtUtc)), + "Ready" => this.Format(nameof(Resources.CastleSiegeReady_Description), RelativeTime(e.StateEndsAtUtc)), + "Start" => this.Format(nameof(Resources.CastleSiegeStarted_Description), RelativeTime(e.StateEndsAtUtc)), + "End" => e.OwnerGuildId is { } ownerId && await this._getGuildName(ownerId).ConfigureAwait(false) is { } guildName + ? this.Format(nameof(Resources.CastleSiegeEndedWithOwner_Description), Escape(guildName)) + : this.Format(nameof(Resources.CastleSiegeEndedWithoutOwner_Description)), + _ => null, + }; + + return description is null ? null : this.CreateEmbed(e, title, description, CastleSiegeColor); + } + + private DiscordEmbed CreateEmbed(GameEvent gameEvent, string title, string description, int color) + { + var serverName = this._getServerName(gameEvent.ServerId); + var footer = serverName is null ? null : this.Format(nameof(Resources.ServerFooter), serverName); + return new DiscordEmbed(title, description, color, gameEvent.TimestampUtc, footer); + } + + private string GetItemName(MonsterItemDroppedEvent e) + { + var parts = new List(); + if (e.IsExcellent) + { + parts.Add(this.Format(nameof(Resources.ItemQuality_Excellent))); + } + + if (e.IsAncient) + { + parts.Add(this.Format(nameof(Resources.ItemQuality_Ancient))); + } + + parts.Add(this.Translate(e.ItemName)); + if (e.ItemLevel > 0) + { + parts.Add($"+{e.ItemLevel}"); + } + + return string.Join(' ', parts); + } + + private string Translate(string localizedValue) + { + return Escape(new LocalizedString(localizedValue).GetTranslation(this._culture) ?? localizedValue); + } + + private string Format(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); + } +} diff --git a/src/Discord/DiscordWebhookNotifier.cs b/src/Discord/DiscordWebhookNotifier.cs new file mode 100644 index 000000000..53a8215e7 --- /dev/null +++ b/src/Discord/DiscordWebhookNotifier.cs @@ -0,0 +1,135 @@ +// +// 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. +/// +public sealed class DiscordWebhookNotifier : 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 DiscordMessageFormatter _formatter; + 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. + /// + /// The settings. + /// The HTTP client. + /// 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, + HttpClient httpClient, + 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); + + var senders = new Dictionary(); + var senderLogger = loggerFactory.CreateLogger(); + foreach (var (category, url) in settings.Webhooks) + { + if (string.IsNullOrWhiteSpace(url)) + { + continue; + } + + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + this._logger.LogError("The Discord webhook URL of the category {category} is invalid.", category); + continue; + } + + senders[category] = new DiscordWebhookSender(uri, httpClient, senderLogger, settings.MaximumQueuedMessages, timeProvider); + } + + this._senders = senders; + } + + /// + public async ValueTask OnGameEventAsync(GameEvent gameEvent) + { + if (!this.ShouldAnnounce(gameEvent) + || await this._formatter.FormatAsync(gameEvent).ConfigureAwait(false) is not { } message + || !this._senders.TryGetValue(message.Category, out var sender)) + { + return; + } + + sender.Enqueue(message.Embed); + } + + /// + public async ValueTask DisposeAsync() + { + foreach (var sender in this._senders.Values) + { + await sender.DisposeAsync().ConfigureAwait(false); + } + } + + 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 + && this._settings.EventAnnouncingServerIds is { Count: > 0 } serverIds + && !serverIds.Contains(gameEvent.ServerId)) + { + return false; + } + + if (gameEvent is MiniGameEntranceOpenedEvent opened) + { + // Mini games like Blood Castle open all their levels at the same time, but we announce it just once. + this.ForgetOldOpenings(opened.TimestampUtc); + var enterEndsAt = new DateTime(opened.EnterEndsAtUtc.Ticks - (opened.EnterEndsAtUtc.Ticks % TimeSpan.TicksPerMinute), DateTimeKind.Utc); + return this._announcedOpenings.TryAdd((opened.ServerId, opened.MiniGameType, enterEndsAt), opened.TimestampUtc); + } + + return true; + } + + private void ForgetOldOpenings(DateTime now) + { + foreach (var (key, announcedAt) in this._announcedOpenings) + { + if (now - announcedAt > AnnouncedOpeningRetention) + { + this._announcedOpenings.TryRemove(key, out _); + } + } + } +} diff --git a/src/Discord/DiscordWebhookSender.cs b/src/Discord/DiscordWebhookSender.cs new file mode 100644 index 000000000..7f8817366 --- /dev/null +++ b/src/Discord/DiscordWebhookSender.cs @@ -0,0 +1,268 @@ +// +// 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.Net; +using System.Net.Http; +using System.Net.Http.Json; +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. +/// +/// +public sealed class DiscordWebhookSender : IAsyncDisposable +{ + /// + /// 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. + /// + private const int MaximumAttempts = 5; + + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + 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. + /// + /// The URL of the webhook. + /// The HTTP client. + /// The logger. + /// 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) + { + 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) + { + var message = new WebhookMessage( + batch.Select(embed => new WebhookEmbed( + embed.Title, + embed.Description, + embed.Color, + embed.TimestampUtc.ToString("O", CultureInfo.InvariantCulture), + embed.Footer is { } footer ? new WebhookFooter(footer) : null)).ToList(), + new WebhookAllowedMentions([])); + + for (var attempt = 1; attempt <= MaximumAttempts; attempt++) + { + TimeSpan delay; + try + { + using var response = await this._httpClient.PostAsJsonAsync(this._webhookUrl, message, SerializerOptions, cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + { + // When the rate limit is exhausted, we wait until it resets, before sending the next message. + if (response.Headers.TryGetValues("X-RateLimit-Remaining", out var remaining) && remaining.FirstOrDefault() == "0" + && GetDelay(response, "X-RateLimit-Reset-After") is { } resetAfter) + { + await Task.Delay(resetAfter, this._timeProvider, cancellationToken).ConfigureAwait(false); + } + + return; + } + + if (response.StatusCode == HttpStatusCode.TooManyRequests) + { + delay = await GetRetryDelayAsync(response, cancellationToken).ConfigureAwait(false); + 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); + } + 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); + 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); + return; + } + } + catch (HttpRequestException ex) + { + delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); + 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); + } + + 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); + } + + private sealed record RateLimitResponse(double? RetryAfter); + + private sealed record WebhookMessage(IReadOnlyList Embeds, WebhookAllowedMentions AllowedMentions); + + private sealed record WebhookEmbed(string Title, string Description, int Color, string Timestamp, WebhookFooter? Footer); + + private sealed record WebhookFooter(string Text); + + /// + /// Defines which mentions are allowed. An empty list prevents that texts of players can ping anybody, e.g. with @everyone. + /// + private sealed record WebhookAllowedMentions(IReadOnlyList Parse); +} diff --git a/src/Discord/DiscordWebhookSettings.cs b/src/Discord/DiscordWebhookSettings.cs new file mode 100644 index 000000000..daf124178 --- /dev/null +++ b/src/Discord/DiscordWebhookSettings.cs @@ -0,0 +1,49 @@ +// +// 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 webhook notifications. +/// +/// +/// The webhook URLs are secrets: everybody who knows them can post into the channel. +/// So they are part of the application configuration (e.g. environment variables), not of the game configuration in the database. +/// +public class DiscordWebhookSettings +{ + /// + /// The name of the configuration section. + /// + public const string SectionName = "Discord"; + + /// + /// Gets or sets the webhook URLs per . + /// A category without URL isn't posted. + /// + public Dictionary Webhooks { get; set; } = new(); + + /// + /// Gets or sets the language of the messages, e.g. en or de. + /// + public string Language { get; set; } = "en"; + + /// + /// Gets or sets the identifiers of the game servers which announce events like mini games and invasions. + /// When several game servers run the same events, this avoids duplicate announcements. + /// If it's empty, all game servers announce them. + /// + public List EventAnnouncingServerIds { get; set; } = new(); + + /// + /// Gets or sets the maximum number of messages per webhook 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. + /// + public bool IsEnabled => this.Webhooks.Values.Any(url => !string.IsNullOrWhiteSpace(url)); +} diff --git a/src/Discord/MUnique.OpenMU.Discord.csproj b/src/Discord/MUnique.OpenMU.Discord.csproj new file mode 100644 index 000000000..25046305c --- /dev/null +++ b/src/Discord/MUnique.OpenMU.Discord.csproj @@ -0,0 +1,43 @@ + + + + net10.0 + enable + nullable;CS4014;VSTHRD110;VSTHRD100 + false + false + + + + ..\..\bin\Debug\ + ..\..\bin\Debug\MUnique.OpenMU.Discord.xml + + + ..\..\bin\Release\ + ..\..\bin\Release\MUnique.OpenMU.Discord.xml + + + + + + + + + + + + + True + True + Resources.resx + + + + + + PublicResXFileCodeGenerator + Resources.Designer.cs + + + + diff --git a/src/Discord/Properties/AssemblyInfo.cs b/src/Discord/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..4810f4734 --- /dev/null +++ b/src/Discord/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.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MUnique.OpenMU.Tests")] diff --git a/src/Discord/Properties/Resources.Designer.cs b/src/Discord/Properties/Resources.Designer.cs new file mode 100644 index 000000000..5b5f77dd0 --- /dev/null +++ b/src/Discord/Properties/Resources.Designer.cs @@ -0,0 +1,261 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace MUnique.OpenMU.Discord.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("MUnique.OpenMU.Discord.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to **{0}** defeated **{1}** in {2}!. + /// + public static string BossKilled_Description { + get { + return ResourceManager.GetString("BossKilled_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Boss defeated. + /// + public static string BossKilled_Title { + get { + return ResourceManager.GetString("BossKilled_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Castle Siege. + /// + public static string CastleSiege_Title { + get { + return ResourceManager.GetString("CastleSiege_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The battle is over. No guild owns the castle.. + /// + public static string CastleSiegeEndedWithoutOwner_Description { + get { + return ResourceManager.GetString("CastleSiegeEndedWithoutOwner_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The battle is over. **{0}** owns the castle.. + /// + public static string CastleSiegeEndedWithOwner_Description { + get { + return ResourceManager.GetString("CastleSiegeEndedWithOwner_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The battle for the castle starts {0}.. + /// + public static string CastleSiegeReady_Description { + get { + return ResourceManager.GetString("CastleSiegeReady_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The guild registration for the castle siege is open until {0}.. + /// + public static string CastleSiegeRegistration_Description { + get { + return ResourceManager.GetString("CastleSiegeRegistration_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The battle for the castle started! It ends {0}.. + /// + public static string CastleSiegeStarted_Description { + get { + return ResourceManager.GetString("CastleSiegeStarted_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Notice. + /// + public static string GlobalNotice_Title { + get { + return ResourceManager.GetString("GlobalNotice_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The invasion is over.. + /// + public static string InvasionEnded_Description { + get { + return ResourceManager.GetString("InvasionEnded_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Monsters invade {0}!. + /// + public static string InvasionStarted_Description { + get { + return ResourceManager.GetString("InvasionStarted_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to **{0}** found **{1}** from {2} in {3}.. + /// + public static string ItemDropped_Description { + get { + return ResourceManager.GetString("ItemDropped_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Rare drop. + /// + public static string ItemDropped_Title { + get { + return ResourceManager.GetString("ItemDropped_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Ancient. + /// + public static string ItemQuality_Ancient { + get { + return ResourceManager.GetString("ItemQuality_Ancient", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Excellent. + /// + public static string ItemQuality_Excellent { + get { + return ResourceManager.GetString("ItemQuality_Excellent", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to **{0}** ({1}) reached level {2}!. + /// + public static string LevelMilestone_Description { + get { + return ResourceManager.GetString("LevelMilestone_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Milestone. + /// + public static string LevelMilestone_Title { + get { + return ResourceManager.GetString("LevelMilestone_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to **{0}** ({1}) reached master level {2}!. + /// + public static string MasterLevelMilestone_Description { + get { + return ResourceManager.GetString("MasterLevelMilestone_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The entrance closes {0}.. + /// + public static string MiniGameEntranceOpened_Description { + get { + return ResourceManager.GetString("MiniGameEntranceOpened_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} opens. + /// + public static string MiniGameEntranceOpened_Title { + get { + return ResourceManager.GetString("MiniGameEntranceOpened_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to **{0}** won {1}!. + /// + public static string MiniGameWon_Description { + get { + return ResourceManager.GetString("MiniGameWon_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Server: {0}. + /// + public static string ServerFooter { + get { + return ResourceManager.GetString("ServerFooter", resourceCulture); + } + } + } +} diff --git a/src/Discord/Properties/Resources.de.resx b/src/Discord/Properties/Resources.de.resx new file mode 100644 index 000000000..780ee59d9 --- /dev/null +++ b/src/Discord/Properties/Resources.de.resx @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + **{0}** hat **{1}** in {2} besiegt! + + + Boss besiegt + + + Burgbelagerung + + + Die Schlacht ist vorbei. Keine Gilde besitzt die Burg. + + + Die Schlacht ist vorbei. **{0}** besitzt die Burg. + + + Die Schlacht um die Burg beginnt {0}. + + + Die Gildenanmeldung für die Burgbelagerung ist offen bis {0}. + + + Die Schlacht um die Burg hat begonnen! Sie endet {0}. + + + Ankündigung + + + Die Invasion ist vorbei. + + + Monster fallen in {0} ein! + + + **{0}** hat **{1}** von {2} in {3} gefunden. + + + Seltener Fund + + + Antik + + + Exzellent + + + **{0}** ({1}) hat Level {2} erreicht! + + + Meilenstein + + + **{0}** ({1}) hat Meisterlevel {2} erreicht! + + + Der Eingang schließt {0}. + + + {0} öffnet + + + **{0}** hat {1} gewonnen! + + + Server: {0} + + diff --git a/src/Discord/Properties/Resources.resx b/src/Discord/Properties/Resources.resx new file mode 100644 index 000000000..39678406b --- /dev/null +++ b/src/Discord/Properties/Resources.resx @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + **{0}** defeated **{1}** in {2}! + + + Boss defeated + + + Castle Siege + + + The battle is over. No guild owns the castle. + + + The battle is over. **{0}** owns the castle. + + + The battle for the castle starts {0}. + + + The guild registration for the castle siege is open until {0}. + + + The battle for the castle started! It ends {0}. + + + Notice + + + The invasion is over. + + + Monsters invade {0}! + + + **{0}** found **{1}** from {2} in {3}. + + + Rare drop + + + Ancient + + + Excellent + + + **{0}** ({1}) reached level {2}! + + + Milestone + + + **{0}** ({1}) reached master level {2}! + + + The entrance closes {0}. + + + {0} opens + + + **{0}** won {1}! + + + Server: {0} + + diff --git a/src/GameLogic/PlugIns/GameEvents/GameEventPublisherConfiguration.cs b/src/GameLogic/PlugIns/GameEvents/GameEventPublisherConfiguration.cs index 29bbf7261..8e4f64954 100644 --- a/src/GameLogic/PlugIns/GameEvents/GameEventPublisherConfiguration.cs +++ b/src/GameLogic/PlugIns/GameEvents/GameEventPublisherConfiguration.cs @@ -44,4 +44,38 @@ public class GameEventPublisherConfiguration /// [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.GameEventPublisherConfiguration_PublishAncientItemDrops_Name))] public bool PublishAncientItemDrops { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the kills of boss monsters are published. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.GameEventPublisherConfiguration_PublishBossKills_Name))] + public bool PublishBossKills { get; set; } = true; + + /// + /// Gets or sets the numbers of the monsters which are bosses, e.g. 275 for Kundun. + /// + /// + /// The monster definitions don't tell which monsters are bosses, so the server owner decides it here. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.GameEventPublisherConfiguration_BossMonsterNumbers_Name))] + public IList BossMonsterNumbers { get; set; } = new List(); + + /// + /// Gets or sets a value indicating whether characters which reach one of the + /// or are published. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.GameEventPublisherConfiguration_PublishLevelMilestones_Name))] + public bool PublishLevelMilestones { get; set; } = true; + + /// + /// Gets or sets the levels which are published when a character reaches them. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.GameEventPublisherConfiguration_LevelMilestones_Name))] + public IList LevelMilestones { get; set; } = new List { 400 }; + + /// + /// Gets or sets the master levels which are published when a character reaches them. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.GameEventPublisherConfiguration_MasterLevelMilestones_Name))] + public IList MasterLevelMilestones { get; set; } = new List(); } diff --git a/src/GameLogic/PlugIns/GameEvents/GameEventPublisherPlugIn.cs b/src/GameLogic/PlugIns/GameEvents/GameEventPublisherPlugIn.cs index 2d4ee49f4..865aeb10f 100644 --- a/src/GameLogic/PlugIns/GameEvents/GameEventPublisherPlugIn.cs +++ b/src/GameLogic/PlugIns/GameEvents/GameEventPublisherPlugIn.cs @@ -6,6 +6,7 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.GameEvents; using System.Reflection; using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.CastleSiege; using MUnique.OpenMU.GameLogic.MiniGames; using MUnique.OpenMU.GameLogic.NPC; @@ -32,6 +33,9 @@ public class GameEventPublisherPlugIn ICastleSiegeStateChangedPlugIn, IMonsterItemDroppedPlugIn, IChatMessageSentPlugIn, + IAttackableGotKilledPlugIn, + ICharacterLevelUpPlugIn, + ICharacterMasterLevelUpPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration, IDisabledByDefault @@ -188,6 +192,87 @@ public ValueTask ChatMessageSentAsync(Player sender, string message, ChatMessage return context.EventPublisher.GameEventAsync(new GlobalNoticeEvent(context.Id, DateTime.UtcNow, sender.Name, message.TrimStart('!'))); } + /// + public ValueTask AttackableGotKilledAsync(IAttackable killed, IAttacker? killer) + { + if (killed is not Monster monster + || this.Configuration is not { PublishBossKills: true } configuration + || !configuration.BossMonsterNumbers.Contains(monster.Definition.Number)) + { + return ValueTask.CompletedTask; + } + + // A summoned monster kills on behalf of its owner. + var player = killer as Player ?? (killer as Monster)?.SummonedBy; + if (player?.GameContext is not IGameServerContext context) + { + return ValueTask.CompletedTask; + } + + return context.EventPublisher.GameEventAsync(new BossKilledEvent( + context.Id, + DateTime.UtcNow, + player.Name, + monster.Definition.Designation, + monster.CurrentMap.Definition.Name)); + } + + /// + public void CharacterLeveledUp(Player player) + { + if (this.Configuration is not { PublishLevelMilestones: true } configuration + || player.Attributes is not { } attributes) + { + return; + } + + var level = (int)attributes[Stats.Level]; + if (configuration.LevelMilestones.Contains(level)) + { + // The plugin point is synchronous, so the event is published in the background. + _ = PublishMilestoneAsync(player, level, false); + } + } + + /// + public ValueTask CharacterMasterLeveledUpAsync(Player player) + { + if (this.Configuration is not { PublishLevelMilestones: true } configuration + || player.Attributes is not { } attributes) + { + return ValueTask.CompletedTask; + } + + var level = (int)attributes[Stats.MasterLevel]; + return configuration.MasterLevelMilestones.Contains(level) + ? new ValueTask(PublishMilestoneAsync(player, level, true)) + : ValueTask.CompletedTask; + } + + private static async Task PublishMilestoneAsync(Player player, int level, bool isMasterLevel) + { + if (player.GameContext is not IGameServerContext context + || player.SelectedCharacter is not { } character) + { + return; + } + + try + { + await context.EventPublisher.GameEventAsync(new CharacterLevelMilestoneEvent( + context.Id, + DateTime.UtcNow, + character.Name, + (string?)character.CharacterClass?.Name ?? string.Empty, + level, + isMasterLevel)).ConfigureAwait(false); + } + catch (Exception ex) + { + player.Logger.LogError(ex, "Error when publishing the level milestone of {character}", character.Name); + } + } + private static (Guid Id, string Name) GetInvasionIdentity(IPeriodicTaskPlugIn invasion) { var type = invasion.GetType(); diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 9c0faae60..5e5ae54d3 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -879,6 +879,33 @@ public static string FruitConsumeHandlerPlugIn_Name { } } + /// + /// Looks up a localized string similar to Boss monster numbers. + /// + public static string GameEventPublisherConfiguration_BossMonsterNumbers_Name { + get { + return ResourceManager.GetString("GameEventPublisherConfiguration_BossMonsterNumbers_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Level milestones. + /// + public static string GameEventPublisherConfiguration_LevelMilestones_Name { + get { + return ResourceManager.GetString("GameEventPublisherConfiguration_LevelMilestones_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Master level milestones. + /// + public static string GameEventPublisherConfiguration_MasterLevelMilestones_Name { + get { + return ResourceManager.GetString("GameEventPublisherConfiguration_MasterLevelMilestones_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Publish ancient item drops. /// @@ -888,6 +915,15 @@ public static string GameEventPublisherConfiguration_PublishAncientItemDrops_Nam } } + /// + /// Looks up a localized string similar to Publish boss kills. + /// + public static string GameEventPublisherConfiguration_PublishBossKills_Name { + get { + return ResourceManager.GetString("GameEventPublisherConfiguration_PublishBossKills_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Publish castle siege events. /// @@ -924,6 +960,15 @@ public static string GameEventPublisherConfiguration_PublishInvasionEvents_Name } } + /// + /// Looks up a localized string similar to Publish level milestones. + /// + public static string GameEventPublisherConfiguration_PublishLevelMilestones_Name { + get { + return ResourceManager.GetString("GameEventPublisherConfiguration_PublishLevelMilestones_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Publish mini game events. /// diff --git a/src/GameLogic/Properties/PlugInResources.de.resx b/src/GameLogic/Properties/PlugInResources.de.resx index d17feaea2..a3ac7e186 100644 --- a/src/GameLogic/Properties/PlugInResources.de.resx +++ b/src/GameLogic/Properties/PlugInResources.de.resx @@ -1752,6 +1752,21 @@ Antike Gegenstände veröffentlichen + + Getötete Bossmonster veröffentlichen + + + Nummern der Bossmonster + + + Erreichte Level-Meilensteine veröffentlichen + + + Level-Meilensteine + + + Meisterlevel-Meilensteine + Burgbelagerung diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index c44389685..fd2a006a9 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1622,6 +1622,21 @@ Publish ancient item drops + + Publish boss kills + + + Boss monster numbers + + + Publish level milestones + + + Level milestones + + + Master level milestones + Player shop currencies diff --git a/src/Interfaces/BossKilledEvent.cs b/src/Interfaces/BossKilledEvent.cs new file mode 100644 index 000000000..9cd82b2f6 --- /dev/null +++ b/src/Interfaces/BossKilledEvent.cs @@ -0,0 +1,20 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Interfaces; + +/// +/// A boss monster got killed. +/// +/// +/// Names of configuration objects, like maps or monsters, are passed as the full value of a , +/// so that a receiver can translate them with new LocalizedString(name).GetTranslation(culture). +/// +/// The identifier of the game server on which the event happened. +/// The timestamp, in UTC, when the event happened. +/// The character name of the player who killed the boss. +/// The name of the boss. +/// The name of the map. +public sealed record BossKilledEvent(byte ServerId, DateTime TimestampUtc, string KillerName, string MonsterName, string MapName) + : GameEvent(ServerId, TimestampUtc); diff --git a/src/Interfaces/CharacterLevelMilestoneEvent.cs b/src/Interfaces/CharacterLevelMilestoneEvent.cs new file mode 100644 index 000000000..8382da190 --- /dev/null +++ b/src/Interfaces/CharacterLevelMilestoneEvent.cs @@ -0,0 +1,21 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Interfaces; + +/// +/// A character reached a notable level or master level. +/// +/// +/// Names of configuration objects, like maps or monsters, are passed as the full value of a , +/// so that a receiver can translate them with new LocalizedString(name).GetTranslation(culture). +/// +/// The identifier of the game server on which the event happened. +/// The timestamp, in UTC, when the event happened. +/// The name of the character. +/// The name of the class of the character. +/// The reached level. +/// A value indicating whether is a master level. +public sealed record CharacterLevelMilestoneEvent(byte ServerId, DateTime TimestampUtc, string CharacterName, string CharacterClassName, int Level, bool IsMasterLevel) + : GameEvent(ServerId, TimestampUtc); diff --git a/src/Interfaces/GameEvent.cs b/src/Interfaces/GameEvent.cs index 75a24661b..b2c258675 100644 --- a/src/Interfaces/GameEvent.cs +++ b/src/Interfaces/GameEvent.cs @@ -26,4 +26,6 @@ namespace MUnique.OpenMU.Interfaces; [JsonDerivedType(typeof(CastleSiegeStateChangedEvent), nameof(CastleSiegeStateChangedEvent))] [JsonDerivedType(typeof(MonsterItemDroppedEvent), nameof(MonsterItemDroppedEvent))] [JsonDerivedType(typeof(GlobalNoticeEvent), nameof(GlobalNoticeEvent))] +[JsonDerivedType(typeof(BossKilledEvent), nameof(BossKilledEvent))] +[JsonDerivedType(typeof(CharacterLevelMilestoneEvent), nameof(CharacterLevelMilestoneEvent))] public abstract record GameEvent(byte ServerId, DateTime TimestampUtc); diff --git a/src/MUnique.OpenMU.sln b/src/MUnique.OpenMU.sln index 4c9bc2f0c..951ab4bd9 100644 --- a/src/MUnique.OpenMU.sln +++ b/src/MUnique.OpenMU.sln @@ -18,6 +18,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MUnique.OpenMU.Tests", "..\ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MUnique.OpenMU.ChatServer", "ChatServer\MUnique.OpenMU.ChatServer.csproj", "{DFCE247F-6E84-4791-A8F3-84AF4C371A2E}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MUnique.OpenMU.Discord", "Discord\MUnique.OpenMU.Discord.csproj", "{6ED14F3F-FD4A-444E-A672-248CEED701B6}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MUnique.OpenMU.ConnectServer", "ConnectServer\MUnique.OpenMU.ConnectServer.csproj", "{06BB9B23-7466-4DAE-8F7C-C1094975462F}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MUnique.OpenMU.GuildServer", "GuildServer\MUnique.OpenMU.GuildServer.csproj", "{D2BAED1F-EB47-4D42-9E8B-1FF7FC708CAD}" @@ -206,6 +208,10 @@ Global {DFCE247F-6E84-4791-A8F3-84AF4C371A2E}.Debug|Any CPU.Build.0 = Debug|Any CPU {DFCE247F-6E84-4791-A8F3-84AF4C371A2E}.Release|Any CPU.ActiveCfg = Release|Any CPU {DFCE247F-6E84-4791-A8F3-84AF4C371A2E}.Release|Any CPU.Build.0 = Release|Any CPU + {6ED14F3F-FD4A-444E-A672-248CEED701B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6ED14F3F-FD4A-444E-A672-248CEED701B6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6ED14F3F-FD4A-444E-A672-248CEED701B6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6ED14F3F-FD4A-444E-A672-248CEED701B6}.Release|Any CPU.Build.0 = Release|Any CPU {06BB9B23-7466-4DAE-8F7C-C1094975462F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {06BB9B23-7466-4DAE-8F7C-C1094975462F}.Debug|Any CPU.Build.0 = Debug|Any CPU {06BB9B23-7466-4DAE-8F7C-C1094975462F}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -415,6 +421,7 @@ Global {CB8A2C80-8ECD-44D6-98B7-AB35891B66FD} = {09EAF85E-358B-4065-8C81-B0B1FF6C0A09} {B3737BB5-05AE-4AEE-8CE7-161B78B9F985} = {6443D0E9-82F8-4A04-BB9C-72CE36E1D952} {DFCE247F-6E84-4791-A8F3-84AF4C371A2E} = {D7B69FAD-38AD-4D95-B3B8-5754E42F35A0} + {6ED14F3F-FD4A-444E-A672-248CEED701B6} = {D7B69FAD-38AD-4D95-B3B8-5754E42F35A0} {06BB9B23-7466-4DAE-8F7C-C1094975462F} = {D7B69FAD-38AD-4D95-B3B8-5754E42F35A0} {D2BAED1F-EB47-4D42-9E8B-1FF7FC708CAD} = {D7B69FAD-38AD-4D95-B3B8-5754E42F35A0} {A956C55B-08A8-41B9-BF72-03427BC6BB22} = {D7B69FAD-38AD-4D95-B3B8-5754E42F35A0} diff --git a/src/Startup/DiscordServiceCollectionExtensions.cs b/src/Startup/DiscordServiceCollectionExtensions.cs new file mode 100644 index 000000000..509967273 --- /dev/null +++ b/src/Startup/DiscordServiceCollectionExtensions.cs @@ -0,0 +1,42 @@ +// +// 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/MUnique.OpenMU.Startup.csproj b/src/Startup/MUnique.OpenMU.Startup.csproj index 4ff7cb9d0..130c0169c 100644 --- a/src/Startup/MUnique.OpenMU.Startup.csproj +++ b/src/Startup/MUnique.OpenMU.Startup.csproj @@ -31,6 +31,7 @@ + diff --git a/src/Startup/Program.cs b/src/Startup/Program.cs index bc6cd7d8b..291a8d616 100644 --- a/src/Startup/Program.cs +++ b/src/Startup/Program.cs @@ -344,6 +344,7 @@ private async Task CreateHostAsync(string[] args) .AddSingleton() .AddSingleton() .AddSingleton() + .AddDiscordWebhookNotifier(builder.Configuration, this._gameServers) .AddSingleton() .AddSingleton(s => s.GetService()!) .AddSingleton() diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordMessageFormatterTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordMessageFormatterTest.cs new file mode 100644 index 000000000..0dba21fdd --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordMessageFormatterTest.cs @@ -0,0 +1,121 @@ +// +// 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; +using MUnique.OpenMU.Interfaces; + +/// +/// Tests for the . +/// +[TestFixture] +public class DiscordMessageFormatterTest +{ + private static readonly Guid OwnerGuildId = Guid.NewGuid(); + + /// + /// Tests that names are translated into the configured language and the server is named in the footer. + /// + [Test] + public async Task NamesAreTranslatedAsync() + { + var formatter = CreateFormatter("de"); + + var message = await formatter.FormatAsync(new BossKilledEvent(1, DateTime.UtcNow, "Hero", "Kundun||de=Kundun DE", "Kalima 7||de=Kalima Sieben")).ConfigureAwait(false); + + Assert.That(message?.Category, Is.EqualTo(DiscordChannelCategory.WorldNews)); + Assert.That(message?.Embed.Title, Is.EqualTo("Boss besiegt")); + Assert.That(message?.Embed.Description, Is.EqualTo("**Hero** hat **Kundun DE** in Kalima Sieben besiegt!")); + Assert.That(message?.Embed.Footer, Is.EqualTo("Server: Server 1")); + } + + /// + /// Tests that markdown in texts of players is escaped. + /// + [Test] + public async Task MarkdownIsEscapedAsync() + { + var formatter = CreateFormatter("en"); + + var message = await formatter.FormatAsync(new GlobalNoticeEvent(1, DateTime.UtcNow, "GM", "**Event** at _8_")).ConfigureAwait(false); + + Assert.That(message?.Category, Is.EqualTo(DiscordChannelCategory.Notices)); + Assert.That(message?.Embed.Description, Is.EqualTo(@"\*\*Event\*\* at \_8\_")); + } + + /// + /// Tests that the name of an item drop contains its qualities and level. + /// + [Test] + public async Task ItemDropContainsQualityAndLevelAsync() + { + var formatter = CreateFormatter("en"); + + var message = await formatter.FormatAsync(new MonsterItemDroppedEvent(1, DateTime.UtcNow, "Hero", "Kundun", "Kalima 7", "Sword of Destruction", 9, true, true)).ConfigureAwait(false); + + Assert.That(message?.Embed.Description, Is.EqualTo("**Hero** found **Excellent Ancient Sword of Destruction +9** from Kundun in Kalima 7.")); + } + + /// + /// Tests that the end of the castle siege names the owner of the castle. + /// + [Test] + public async Task CastleSiegeEndNamesTheOwnerAsync() + { + var formatter = CreateFormatter("en"); + + var message = await formatter.FormatAsync(new CastleSiegeStateChangedEvent(1, DateTime.UtcNow, "Start", "End", DateTime.UtcNow, OwnerGuildId)).ConfigureAwait(false); + + Assert.That(message?.Category, Is.EqualTo(DiscordChannelCategory.CastleSiege)); + Assert.That(message?.Embed.Description, Is.EqualTo("The battle is over. **Winners** owns the castle.")); + } + + /// + /// Tests that the opening of a mini game contains the time when the entrance closes, as Discord time markup. + /// + [Test] + public async Task MiniGameOpeningContainsClosingTimeAsync() + { + var formatter = CreateFormatter("en"); + var closesAt = new DateTime(2026, 10, 8, 12, 0, 0, DateTimeKind.Utc); + + var message = await formatter.FormatAsync(new MiniGameEntranceOpenedEvent(1, DateTime.UtcNow, "BloodCastle", "Blood Castle", 1, closesAt)).ConfigureAwait(false); + + Assert.That(message?.Embed.Title, Is.EqualTo("Blood Castle opens")); + Assert.That(message?.Embed.Description, Is.EqualTo($"The entrance closes .")); + } + + /// + /// Tests that events which aren't interesting for the players aren't posted. + /// + /// The index of the event in . + [TestCase(0)] + [TestCase(1)] + [TestCase(2)] + public async Task UninterestingEventIsNotPostedAsync(int index) + { + var formatter = CreateFormatter("en"); + + var message = await formatter.FormatAsync(GetIgnoredEvents()[index]).ConfigureAwait(false); + + Assert.That(message, Is.Null); + } + + private static GameEvent[] GetIgnoredEvents() => new GameEvent[] + { + new MiniGameStartedEvent(1, DateTime.UtcNow, "BloodCastle", "Blood Castle", 1, 5), + new MiniGameEndedEvent(1, DateTime.UtcNow, "DevilSquare", "Devil Square", 1, null, Array.Empty()), + new CastleSiegeStateChangedEvent(1, DateTime.UtcNow, "Idle1", "Idle2", DateTime.UtcNow, null), + }; + + private static DiscordMessageFormatter CreateFormatter(string language) + { + return new DiscordMessageFormatter( + CultureInfo.GetCultureInfo(language), + serverId => $"Server {serverId}", + guildId => ValueTask.FromResult(guildId == OwnerGuildId ? "Winners" : null)); + } +} diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordWebhookNotifierTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordWebhookNotifierTest.cs new file mode 100644 index 000000000..0d6e6d690 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordWebhookNotifierTest.cs @@ -0,0 +1,86 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Discord; + +using System.Net.Http; +using Microsoft.Extensions.Logging.Abstractions; +using MUnique.OpenMU.Discord; +using MUnique.OpenMU.Interfaces; + +/// +/// Tests for the . +/// +[TestFixture] +public class DiscordWebhookNotifierTest +{ + /// + /// Tests that a mini game which opens several levels at the same time is announced just once. + /// + [Test] + public async Task MiniGameOpeningIsAnnouncedOnceAsync() + { + var handler = new RecordingHttpMessageHandler(); + await using var notifier = CreateNotifier(handler, new DiscordWebhookSettings()); + var closesAt = DateTime.UtcNow.AddMinutes(5); + + for (byte level = 1; level <= 7; level++) + { + await notifier.OnGameEventAsync(new MiniGameEntranceOpenedEvent(0, DateTime.UtcNow, "BloodCastle", "Blood Castle", level, closesAt.AddMilliseconds(level))).ConfigureAwait(false); + } + + await handler.WaitForRequestsAsync(1).ConfigureAwait(false); + await Task.Delay(200).ConfigureAwait(false); + Assert.That(handler.Requests.Sum(r => r.RootElement.GetProperty("embeds").GetArrayLength()), Is.EqualTo(1)); + } + + /// + /// Tests that the events of game servers which aren't configured to announce them, aren't posted. + /// + [Test] + public async Task EventsOfOtherServersAreNotAnnouncedAsync() + { + var handler = new RecordingHttpMessageHandler(); + await using var notifier = CreateNotifier(handler, new DiscordWebhookSettings { 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); + + await handler.WaitForRequestsAsync(1).ConfigureAwait(false); + await Task.Delay(200).ConfigureAwait(false); + var descriptions = handler.Requests.SelectMany(r => r.RootElement.GetProperty("embeds").EnumerateArray()).Select(e => e.GetProperty("description").GetString()); + Assert.That(descriptions, Is.EqualTo(new[] { "Monsters invade Devias!" })); + } + + /// + /// Tests that events of a category without webhook aren't posted. + /// + [Test] + public async Task CategoryWithoutWebhookIsNotPostedAsync() + { + var handler = new RecordingHttpMessageHandler(); + await using var notifier = CreateNotifier(handler, new DiscordWebhookSettings()); + + 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); + + await handler.WaitForRequestsAsync(1).ConfigureAwait(false); + await Task.Delay(200).ConfigureAwait(false); + var titles = handler.Requests.SelectMany(r => r.RootElement.GetProperty("embeds").EnumerateArray()).Select(e => e.GetProperty("title").GetString()); + Assert.That(titles, Is.EqualTo(new[] { "Boss defeated" })); + } + + private static DiscordWebhookNotifier CreateNotifier(RecordingHttpMessageHandler handler, DiscordWebhookSettings settings) + { + // 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( + settings, + new HttpClient(handler), + _ => null, + _ => ValueTask.FromResult(null), + NullLoggerFactory.Instance); + } +} diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordWebhookSenderTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordWebhookSenderTest.cs new file mode 100644 index 000000000..910e3599d --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordWebhookSenderTest.cs @@ -0,0 +1,168 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Discord; + +using System.Net; +using System.Net.Http; +using Microsoft.Extensions.Logging.Abstractions; +using MUnique.OpenMU.Discord; + +/// +/// Tests for the . +/// +[TestFixture] +public class DiscordWebhookSenderTest +{ + private static readonly Uri WebhookUrl = new("https://discord.example/api/webhooks/1/token"); + + /// + /// Tests that a message is posted with its embed, and that mentions are disabled. + /// + [Test] + public async Task MessageIsPostedWithoutMentionsAsync() + { + var handler = new RecordingHttpMessageHandler(); + await using var sender = CreateSender(handler); + + sender.Enqueue(CreateEmbed("@everyone hello")); + await handler.WaitForRequestsAsync(1).ConfigureAwait(false); + + var message = handler.Requests[0].RootElement; + Assert.That(message.GetProperty("embeds")[0].GetProperty("title").GetString(), Is.EqualTo("@everyone hello")); + Assert.That(message.GetProperty("embeds")[0].GetProperty("footer").GetProperty("text").GetString(), Is.EqualTo("Server: Test")); + Assert.That(message.GetProperty("allowed_mentions").GetProperty("parse").GetArrayLength(), Is.Zero); + } + + /// + /// Tests that the embeds which are queued while a message is sent, are combined into the next message. + /// + [Test] + public async Task QueuedEmbedsAreCombinedAsync() + { + var release = new TaskCompletionSource(); + var handler = new RecordingHttpMessageHandler { BlockFirstRequest = release.Task }; + await using var sender = CreateSender(handler); + + sender.Enqueue(CreateEmbed("1")); + await WaitUntilAsync(() => handler.Requests.Count == 1).ConfigureAwait(false); + sender.Enqueue(CreateEmbed("2")); + sender.Enqueue(CreateEmbed("3")); + release.SetResult(); + await handler.WaitForRequestsAsync(2).ConfigureAwait(false); + + Assert.That(handler.Requests, Has.Count.EqualTo(2)); + Assert.That(handler.Requests[1].RootElement.GetProperty("embeds").GetArrayLength(), Is.EqualTo(2)); + } + + /// + /// Tests that a message is sent again after the rate limit was hit. + /// + [Test] + public async Task MessageIsRetriedAfterRateLimitAsync() + { + var handler = new RecordingHttpMessageHandler(); + handler.AddResponse(() => + { + var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests) + { + Content = new StringContent("{\"message\": \"You are being rate limited.\", \"retry_after\": 0.01, \"global\": false}"), + }; + response.Headers.Add("Retry-After", "1"); + return response; + }); + await using var sender = CreateSender(handler); + + sender.Enqueue(CreateEmbed("1")); + await handler.WaitForRequestsAsync(2).ConfigureAwait(false); + + Assert.That(handler.Requests[1].RootElement.GetProperty("embeds")[0].GetProperty("title").GetString(), Is.EqualTo("1")); + } + + /// + /// Tests that a message which Discord rejects, e.g. because the webhook doesn't exist, isn't sent again. + /// + [Test] + public async Task RejectedMessageIsNotRetriedAsync() + { + var handler = new RecordingHttpMessageHandler(); + handler.AddResponse(() => new HttpResponseMessage(HttpStatusCode.NotFound)); + await using var sender = CreateSender(handler); + + sender.Enqueue(CreateEmbed("1")); + sender.Enqueue(CreateEmbed("2")); + await handler.WaitForRequestsAsync(1).ConfigureAwait(false); + await Task.Delay(200).ConfigureAwait(false); + + // The second embed was either combined into the rejected message, or sent separately - but nothing is repeated. + var titles = handler.Requests.SelectMany(r => r.RootElement.GetProperty("embeds").EnumerateArray()).Select(e => e.GetProperty("title").GetString()); + Assert.That(titles, Is.Unique); + } + + /// + /// Tests that the oldest messages are dropped when the queue is full. + /// + [Test] + public async Task OldestMessagesAreDroppedWhenQueueIsFullAsync() + { + var release = new TaskCompletionSource(); + var handler = new RecordingHttpMessageHandler { BlockFirstRequest = release.Task }; + await using var sender = CreateSender(handler, capacity: 2); + + sender.Enqueue(CreateEmbed("1")); + await WaitUntilAsync(() => handler.Requests.Count == 1).ConfigureAwait(false); + sender.Enqueue(CreateEmbed("2")); + sender.Enqueue(CreateEmbed("3")); + sender.Enqueue(CreateEmbed("4")); + release.SetResult(); + await handler.WaitForRequestsAsync(2).ConfigureAwait(false); + + var titles = handler.Requests[1].RootElement.GetProperty("embeds").EnumerateArray().Select(e => e.GetProperty("title").GetString()); + Assert.That(titles, Is.EqualTo(new[] { "3", "4" })); + } + + /// + /// Tests that long texts are cut, and that the embeds of one message don't exceed the total length which Discord accepts. + /// + [Test] + public async Task LongEmbedsAreCutAndSplitAsync() + { + var release = new TaskCompletionSource(); + var handler = new RecordingHttpMessageHandler { BlockFirstRequest = release.Task }; + await using var sender = CreateSender(handler); + var longText = new string('x', 5000); + + sender.Enqueue(CreateEmbed("1")); + await WaitUntilAsync(() => handler.Requests.Count == 1).ConfigureAwait(false); + sender.Enqueue(new DiscordEmbed(new string('t', 300), longText, 0, DateTime.UtcNow, null)); + sender.Enqueue(new DiscordEmbed("3", longText, 0, DateTime.UtcNow, null)); + release.SetResult(); + await handler.WaitForRequestsAsync(3).ConfigureAwait(false); + + Assert.That(handler.Requests, Has.Count.EqualTo(3)); + var second = handler.Requests[1].RootElement.GetProperty("embeds"); + Assert.That(second.GetArrayLength(), Is.EqualTo(1)); + Assert.That(second[0].GetProperty("title").GetString(), Has.Length.EqualTo(DiscordEmbed.MaximumTitleLength)); + Assert.That(second[0].GetProperty("description").GetString(), Has.Length.EqualTo(DiscordEmbed.MaximumDescriptionLength)); + Assert.That(handler.Requests[2].RootElement.GetProperty("embeds")[0].GetProperty("title").GetString(), Is.EqualTo("3")); + } + + private static DiscordWebhookSender CreateSender(RecordingHttpMessageHandler handler, int capacity = 100) + { + return new DiscordWebhookSender(WebhookUrl, new HttpClient(handler), NullLogger.Instance, capacity); + } + + private static DiscordEmbed CreateEmbed(string title) => new(title, "Description", 0x123456, DateTime.UtcNow, "Server: Test"); + + private static async Task WaitUntilAsync(Func condition) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + while (!condition() && DateTime.UtcNow < deadline) + { + await Task.Delay(10).ConfigureAwait(false); + } + + Assert.That(condition(), Is.True, "The condition wasn't met in time."); + } +} diff --git a/tests/MUnique.OpenMU.Tests/Discord/RecordingHttpMessageHandler.cs b/tests/MUnique.OpenMU.Tests/Discord/RecordingHttpMessageHandler.cs new file mode 100644 index 000000000..9f3445793 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Discord/RecordingHttpMessageHandler.cs @@ -0,0 +1,70 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Discord; + +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; + +/// +/// A which records the posted messages and answers with configurable responses. +/// +internal sealed class RecordingHttpMessageHandler : HttpMessageHandler +{ + private readonly Queue> _responses = new(); + private readonly SemaphoreSlim _requestReceived = new(0); + + /// + /// Gets the bodies of the received requests. + /// + public List Requests { get; } = new(); + + /// + /// Gets or sets a task which has to complete before the first request is answered. + /// + public Task? BlockFirstRequest { get; set; } + + /// + /// Adds a response, which is returned for the next request. When there are no more responses, 204 is returned. + /// + /// The function which creates the response. + public void AddResponse(Func response) => this._responses.Enqueue(response); + + /// + /// Waits until the specified number of requests has been received. + /// + /// The number of requests. + public async Task WaitForRequestsAsync(int count) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + while (this.Requests.Count < count && DateTime.UtcNow < deadline) + { + await this._requestReceived.WaitAsync(TimeSpan.FromMilliseconds(100)).ConfigureAwait(false); + } + + Assert.That(this.Requests, Has.Count.GreaterThanOrEqualTo(count), "Not enough requests were received in time."); + } + + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = await request.Content!.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + Func? response; + lock (this.Requests) + { + this.Requests.Add(JsonDocument.Parse(body)); + this._responses.TryDequeue(out response); + } + + if (this.Requests.Count == 1 && this.BlockFirstRequest is { } block) + { + await block.ConfigureAwait(false); + } + + this._requestReceived.Release(); + return response?.Invoke() ?? new HttpResponseMessage(HttpStatusCode.NoContent); + } +} diff --git a/tests/MUnique.OpenMU.Tests/GameEventPublisherPlugInTest.cs b/tests/MUnique.OpenMU.Tests/GameEventPublisherPlugInTest.cs index a94705977..019eca6c1 100644 --- a/tests/MUnique.OpenMU.Tests/GameEventPublisherPlugInTest.cs +++ b/tests/MUnique.OpenMU.Tests/GameEventPublisherPlugInTest.cs @@ -11,6 +11,7 @@ namespace MUnique.OpenMU.Tests; using MUnique.OpenMU.DataModel.Configuration.Items; using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.PlayerActions.Chat; using MUnique.OpenMU.GameLogic.PlugIns; @@ -47,6 +48,8 @@ public class GameEventPublisherPlugInTest new CastleSiegeStateChangedEvent(1, DateTime.UtcNow, "Ready", "Start", DateTime.UtcNow.AddHours(2), Guid.NewGuid()), new MonsterItemDroppedEvent(1, DateTime.UtcNow, "Killer", "Kundun", "Kalima 7", "Sword of Destruction", 13, true, false), new GlobalNoticeEvent(1, DateTime.UtcNow, "GameMaster", "Hello"), + new BossKilledEvent(1, DateTime.UtcNow, "Killer", "Kundun", "Kalima 7"), + new CharacterLevelMilestoneEvent(1, DateTime.UtcNow, "Hero", "Blade Knight", 400, false), }; /// @@ -190,6 +193,76 @@ public async Task ExcellentDropIsNotPublishedWhenDisabledAsync() this._eventPublisher.Verify(p => p.GameEventAsync(It.IsAny()), Times.Never); } + /// + /// Tests that the kill of a configured boss monster is published. + /// + [Test] + public async Task BossKillIsPublishedAsync() + { + var (plugIn, monster, killer) = await this.CreateDropSetupAsync().ConfigureAwait(false); + plugIn.Configuration!.BossMonsterNumbers.Add(monster.Definition.Number); + + await plugIn.AttackableGotKilledAsync(monster, killer).ConfigureAwait(false); + + this._eventPublisher.Verify( + p => p.GameEventAsync(It.Is(e => e.ServerId == ServerId && e.MonsterName == "Kundun" && e.KillerName == killer.Name)), + Times.Once); + } + + /// + /// Tests that the kill of a monster which isn't configured as boss isn't published. + /// + [Test] + public async Task KillOfNormalMonsterIsNotPublishedAsync() + { + var (plugIn, monster, killer) = await this.CreateDropSetupAsync().ConfigureAwait(false); + + await plugIn.AttackableGotKilledAsync(monster, killer).ConfigureAwait(false); + + this._eventPublisher.Verify(p => p.GameEventAsync(It.IsAny()), Times.Never); + } + + /// + /// Tests that reaching a configured level milestone is published, and other levels are not. + /// + [Test] + public async Task LevelMilestoneIsPublishedAsync() + { + var (plugIn, _, player) = await this.CreateDropSetupAsync().ConfigureAwait(false); + plugIn.Configuration!.LevelMilestones = new List { 10 }; + var published = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + this._eventPublisher.Setup(p => p.GameEventAsync(It.IsAny())) + .Callback(e => published.TrySetResult((CharacterLevelMilestoneEvent)e)) + .Returns(ValueTask.CompletedTask); + + player.Attributes![Stats.Level] = 9; + plugIn.CharacterLeveledUp(player); + player.Attributes[Stats.Level] = 10; + plugIn.CharacterLeveledUp(player); + + var milestone = await published.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + Assert.That(milestone.Level, Is.EqualTo(10)); + Assert.That(milestone.IsMasterLevel, Is.False); + this._eventPublisher.Verify(p => p.GameEventAsync(It.IsAny()), Times.Once); + } + + /// + /// Tests that reaching a configured master level milestone is published. + /// + [Test] + public async Task MasterLevelMilestoneIsPublishedAsync() + { + var (plugIn, _, player) = await this.CreateDropSetupAsync().ConfigureAwait(false); + plugIn.Configuration!.MasterLevelMilestones = new List { 5 }; + player.Attributes![Stats.MasterLevel] = 5; + + await plugIn.CharacterMasterLeveledUpAsync(player).ConfigureAwait(false); + + this._eventPublisher.Verify( + p => p.GameEventAsync(It.Is(e => e.Level == 5 && e.IsMasterLevel)), + Times.Once); + } + private static GameEventPublisherPlugIn CreatePlugIn() { return new GameEventPublisherPlugIn { Configuration = new GameEventPublisherConfiguration() }; diff --git a/tests/MUnique.OpenMU.Tests/MUnique.OpenMU.Tests.csproj b/tests/MUnique.OpenMU.Tests/MUnique.OpenMU.Tests.csproj index 303be7dec..aa878c7fc 100644 --- a/tests/MUnique.OpenMU.Tests/MUnique.OpenMU.Tests.csproj +++ b/tests/MUnique.OpenMU.Tests/MUnique.OpenMU.Tests.csproj @@ -41,6 +41,7 @@ +