From 8096405698903104605fecd8571b02fe9dbb4da2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:04:24 +0000 Subject: [PATCH 1/4] feat(events): publish boss kills and level milestones The game event publisher now also publishes the kills of boss monsters and characters which reach a notable level or master level. Which monsters are bosses and which levels are milestones is configured in the plug-in, because the monster definitions don't tell which monsters are bosses. Refs #1099 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ --- .../GameEventPublisherConfiguration.cs | 34 ++++++++ .../GameEvents/GameEventPublisherPlugIn.cs | 85 +++++++++++++++++++ .../Properties/PlugInResources.Designer.cs | 45 ++++++++++ .../Properties/PlugInResources.de.resx | 15 ++++ src/GameLogic/Properties/PlugInResources.resx | 15 ++++ src/Interfaces/BossKilledEvent.cs | 20 +++++ .../CharacterLevelMilestoneEvent.cs | 21 +++++ src/Interfaces/GameEvent.cs | 2 + .../GameEventPublisherPlugInTest.cs | 73 ++++++++++++++++ 9 files changed, 310 insertions(+) create mode 100644 src/Interfaces/BossKilledEvent.cs create mode 100644 src/Interfaces/CharacterLevelMilestoneEvent.cs 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 e9ed4fb97..ac15068dd 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -843,6 +843,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. /// @@ -852,6 +879,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. /// @@ -888,6 +924,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 0746048a7..20be88ed3 100644 --- a/src/GameLogic/Properties/PlugInResources.de.resx +++ b/src/GameLogic/Properties/PlugInResources.de.resx @@ -1752,4 +1752,19 @@ Antike Gegenstände veröffentlichen + + Getötete Bossmonster veröffentlichen + + + Nummern der Bossmonster + + + Erreichte Level-Meilensteine veröffentlichen + + + Level-Meilensteine + + + Meisterlevel-Meilensteine + diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 56a9ea82d..904eaad66 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1595,4 +1595,19 @@ Publish ancient item drops + + Publish boss kills + + + Boss monster numbers + + + Publish level milestones + + + Level milestones + + + Master level milestones + 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/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() }; From 74a52e4490343350c868a79eb999f58075e877ef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:14:40 +0000 Subject: [PATCH 2/4] feat(discord): post game events to Discord webhooks Adds the project MUnique.OpenMU.Discord with a DiscordWebhookNotifier, which listens to the game events and posts them as embeds to Discord webhooks, one webhook per category (events, castle siege, world news, notices). * The messages are localized in the configured language, names of configuration objects are translated, and markdown in texts of players is escaped. * Times are posted as Discord time markup, so each user sees them in their own time zone and language. * Mini games which open several levels at once are announced just once, and the servers which announce events can be restricted, to avoid duplicates. * Each webhook has a bounded queue which is sent in the background. Queued embeds are combined into one message, the rate limit headers and Retry-After are respected, failed requests are retried, and the oldest messages are dropped when the queue is full. * Mentions are disabled, so texts of players can't ping anybody. The project only depends on the interfaces, so it can be hosted by the all-in-one server as well as by a separate bot service later. Refs #1099 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ --- src/Discord/DiscordChannelCategory.cs | 31 +++ src/Discord/DiscordEmbed.cs | 15 + src/Discord/DiscordMessageFormatter.cs | 213 ++++++++++++++ src/Discord/DiscordWebhookNotifier.cs | 135 +++++++++ src/Discord/DiscordWebhookSender.cs | 244 ++++++++++++++++ src/Discord/DiscordWebhookSettings.cs | 49 ++++ src/Discord/MUnique.OpenMU.Discord.csproj | 43 +++ src/Discord/Properties/AssemblyInfo.cs | 7 + src/Discord/Properties/Resources.Designer.cs | 261 ++++++++++++++++++ src/Discord/Properties/Resources.de.resx | 167 +++++++++++ src/Discord/Properties/Resources.resx | 167 +++++++++++ src/MUnique.OpenMU.sln | 7 + .../Discord/DiscordMessageFormatterTest.cs | 121 ++++++++ .../Discord/DiscordWebhookNotifierTest.cs | 86 ++++++ .../Discord/DiscordWebhookSenderTest.cs | 142 ++++++++++ .../Discord/RecordingHttpMessageHandler.cs | 70 +++++ .../MUnique.OpenMU.Tests.csproj | 1 + 17 files changed, 1759 insertions(+) create mode 100644 src/Discord/DiscordChannelCategory.cs create mode 100644 src/Discord/DiscordEmbed.cs create mode 100644 src/Discord/DiscordMessageFormatter.cs create mode 100644 src/Discord/DiscordWebhookNotifier.cs create mode 100644 src/Discord/DiscordWebhookSender.cs create mode 100644 src/Discord/DiscordWebhookSettings.cs create mode 100644 src/Discord/MUnique.OpenMU.Discord.csproj create mode 100644 src/Discord/Properties/AssemblyInfo.cs create mode 100644 src/Discord/Properties/Resources.Designer.cs create mode 100644 src/Discord/Properties/Resources.de.resx create mode 100644 src/Discord/Properties/Resources.resx create mode 100644 tests/MUnique.OpenMU.Tests/Discord/DiscordMessageFormatterTest.cs create mode 100644 tests/MUnique.OpenMU.Tests/Discord/DiscordWebhookNotifierTest.cs create mode 100644 tests/MUnique.OpenMU.Tests/Discord/DiscordWebhookSenderTest.cs create mode 100644 tests/MUnique.OpenMU.Tests/Discord/RecordingHttpMessageHandler.cs 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..10545b236 --- /dev/null +++ b/src/Discord/DiscordEmbed.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; + +/// +/// 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); 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..c74ac527a --- /dev/null +++ b/src/Discord/DiscordWebhookSender.cs @@ -0,0 +1,244 @@ +// +// 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); + + /// + 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; + try + { + while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) + { + var batch = new List(MaximumEmbedsPerMessage); + while (batch.Count < MaximumEmbedsPerMessage && reader.TryRead(out var embed)) + { + batch.Add(embed); + } + + 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 + { + // 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); + } + + 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/MUnique.OpenMU.sln b/src/MUnique.OpenMU.sln index 9e797d88e..7915359c0 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}" @@ -202,6 +204,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 @@ -403,6 +409,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/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..ceb4bf057 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordWebhookSenderTest.cs @@ -0,0 +1,142 @@ +// +// 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" })); + } + + 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/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 @@ + From ab9a0dc5e8223a590af777cd2bf5c1f2edd19ad2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:24:12 +0000 Subject: [PATCH 3/4] feat(discord): enable the Discord notifications in the all-in-one server The all-in-one server registers the Discord webhook notifier as listener for the game events, when at least one webhook is configured in the "Discord" configuration section, e.g. with the environment variable Discord__Webhooks__Events. The new documentation page describes how to set it up. Refs #1099 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ --- docs-website/docs/admin-panel/plugins.md | 11 ++- docs-website/docs/server-features/discord.md | 78 +++++++++++++++++++ docs-website/sidebars.js | 1 + .../DiscordServiceCollectionExtensions.cs | 42 ++++++++++ src/Startup/MUnique.OpenMU.Startup.csproj | 1 + src/Startup/Program.cs | 1 + 6 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 docs-website/docs/server-features/discord.md create mode 100644 src/Startup/DiscordServiceCollectionExtensions.cs diff --git a/docs-website/docs/admin-panel/plugins.md b/docs-website/docs/admin-panel/plugins.md index 579159c37..443513455 100644 --- a/docs-website/docs/admin-panel/plugins.md +++ b/docs-website/docs/admin-panel/plugins.md @@ -70,8 +70,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: @@ -79,9 +79,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). ## Writing your own plugin 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 54cf95ae3..55c957660 100644 --- a/docs-website/sidebars.js +++ b/docs-website/sidebars.js @@ -63,6 +63,7 @@ const sidebars = { 'server-features/test-actors', 'server-features/gens', 'server-features/cash-shop', + 'server-features/discord', ], }, { diff --git a/src/Startup/DiscordServiceCollectionExtensions.cs b/src/Startup/DiscordServiceCollectionExtensions.cs new file mode 100644 index 000000000..374f8da4f --- /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, + new HttpClient(), + 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 870bf3c49..5d64b29e7 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 77cc036ed..7c85ad4dc 100644 --- a/src/Startup/Program.cs +++ b/src/Startup/Program.cs @@ -310,6 +310,7 @@ private async Task CreateHostAsync(string[] args) .AddSingleton() .AddSingleton() .AddSingleton() + .AddDiscordWebhookNotifier(builder.Configuration, this._gameServers) .AddSingleton() .AddSingleton(s => s.GetService()!) .AddSingleton() From f2b2600ea516eb55fd21263dfb824b760c21afc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 10:01:27 +0000 Subject: [PATCH 4/4] fix(discord): respect the limits of Discord for embeds and time out requests Titles, descriptions and footers are cut to the lengths which Discord accepts, and the embeds of one message don't exceed the total length. Content which Discord rejects with 400 is logged with the response, not as an invalid webhook. The HTTP client times out after 10 seconds instead of 100, and a timed-out request is retried. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ --- src/Discord/DiscordEmbed.cs | 51 ++++++++++++++++++- src/Discord/DiscordHttpClient.cs | 26 ++++++++++ src/Discord/DiscordWebhookSender.cs | 30 +++++++++-- .../DiscordServiceCollectionExtensions.cs | 2 +- .../Discord/DiscordWebhookSenderTest.cs | 26 ++++++++++ 5 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 src/Discord/DiscordHttpClient.cs diff --git a/src/Discord/DiscordEmbed.cs b/src/Discord/DiscordEmbed.cs index 10545b236..130fddf64 100644 --- a/src/Discord/DiscordEmbed.cs +++ b/src/Discord/DiscordEmbed.cs @@ -12,4 +12,53 @@ namespace MUnique.OpenMU.Discord; /// 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); +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/DiscordWebhookSender.cs b/src/Discord/DiscordWebhookSender.cs index c74ac527a..7f8817366 100644 --- a/src/Discord/DiscordWebhookSender.cs +++ b/src/Discord/DiscordWebhookSender.cs @@ -85,7 +85,7 @@ public DiscordWebhookSender(Uri webhookUrl, HttpClient httpClient, ILogger logge /// /// The embed. /// , if the embed was queued; , if the sender is shutting down. - public bool Enqueue(DiscordEmbed embed) => this._queue.Writer.TryWrite(embed); + public bool Enqueue(DiscordEmbed embed) => this._queue.Writer.TryWrite(embed.Limit()); /// public async ValueTask DisposeAsync() @@ -144,14 +144,25 @@ private async Task RunAsync() { var reader = this._queue.Reader; var cancellationToken = this._cancellation.Token; + DiscordEmbed? pending = null; try { - while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) + 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); - while (batch.Count < MaximumEmbedsPerMessage && reader.TryRead(out var embed)) + 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 @@ -210,6 +221,13 @@ private async Task SendAsync(IReadOnlyList batch, CancellationToke 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. @@ -222,6 +240,12 @@ private async Task SendAsync(IReadOnlyList batch, CancellationToke 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); } diff --git a/src/Startup/DiscordServiceCollectionExtensions.cs b/src/Startup/DiscordServiceCollectionExtensions.cs index 374f8da4f..509967273 100644 --- a/src/Startup/DiscordServiceCollectionExtensions.cs +++ b/src/Startup/DiscordServiceCollectionExtensions.cs @@ -34,7 +34,7 @@ public static IServiceCollection AddDiscordWebhookNotifier(this IServiceCollecti return services.AddSingleton(provider => new DiscordWebhookNotifier( settings, - new HttpClient(), + DiscordHttpClient.Create(), serverId => gameServers.Values.FirstOrDefault(server => server.Id == serverId)?.Description, guildId => provider.GetRequiredService().GetPersistentGuildNameAsync(guildId), provider.GetRequiredService())); diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordWebhookSenderTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordWebhookSenderTest.cs index ceb4bf057..910e3599d 100644 --- a/tests/MUnique.OpenMU.Tests/Discord/DiscordWebhookSenderTest.cs +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordWebhookSenderTest.cs @@ -122,6 +122,32 @@ public async Task OldestMessagesAreDroppedWhenQueueIsFullAsync() 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);