From 00b745fba9ffef589fae3be83bdbf81c8745491b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:25:50 +0000 Subject: [PATCH 1/3] feat(packets): Discord integration packets for MuMain Opt-in F5 packets for the client side of the Discord integration (sven-n/MuMain#696): - DiscordIntegrationInfoRequest (F5 02) / DiscordIntegrationInfo (F5 03): link state, which chats are bridged, the Rich Presence application and image keys, and the invite link. The request also announces that the client understands ExternalChatMessage. - DiscordLinkCodeRequest (F5 04) / DiscordLinkCode (F5 05): the one-time code to link the account, without typing /discord link. - DiscordUnlinkRequest (F5 06): removes the link; answered with a new DiscordIntegrationInfo. - ExternalChatMessage (F5 07): a message written in Discord, with source, scope (guild, alliance, world) and a sender name which isn't a character. Generated code, docs and packet tests are regenerated from the XML. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012feEciycvL8PGjsezyRFqj --- ...DiscordIntegrationInfoRequest_by-client.md | 18 + ...-F5-03-DiscordIntegrationInfo_by-server.md | 27 ++ ...-F5-04-DiscordLinkCodeRequest_by-client.md | 18 + .../C1-F5-05-DiscordLinkCode_by-server.md | 30 ++ ...C1-F5-06-DiscordUnlinkRequest_by-client.md | 18 + .../C2-F5-07-ExternalChatMessage_by-server.md | 40 ++ docs/Packets/ClientToServer.md | 3 + docs/Packets/ServerToClient.md | 3 + .../ClientToServer/ClientToServerPackets.cs | 231 ++++++++++ .../ClientToServer/ClientToServerPackets.xml | 36 ++ .../ClientToServerPacketsRef.cs | 231 ++++++++++ .../ClientToServer/ConnectionExtensions.cs | 75 +++ .../ServerToClient/ConnectionExtensions.cs | 110 +++++ .../ServerToClient/ServerToClientPackets.cs | 430 ++++++++++++++++++ .../ServerToClient/ServerToClientPackets.xml | 189 ++++++++ .../ServerToClientPacketsRef.cs | 382 ++++++++++++++++ .../ClientToServerPacketTests.cs | 42 ++ .../ServerToClientPacketTests.cs | 92 ++++ 18 files changed, 1975 insertions(+) create mode 100644 docs/Packets/C1-F5-02-DiscordIntegrationInfoRequest_by-client.md create mode 100644 docs/Packets/C1-F5-03-DiscordIntegrationInfo_by-server.md create mode 100644 docs/Packets/C1-F5-04-DiscordLinkCodeRequest_by-client.md create mode 100644 docs/Packets/C1-F5-05-DiscordLinkCode_by-server.md create mode 100644 docs/Packets/C1-F5-06-DiscordUnlinkRequest_by-client.md create mode 100644 docs/Packets/C2-F5-07-ExternalChatMessage_by-server.md diff --git a/docs/Packets/C1-F5-02-DiscordIntegrationInfoRequest_by-client.md b/docs/Packets/C1-F5-02-DiscordIntegrationInfoRequest_by-client.md new file mode 100644 index 0000000000..7b3b21b8b2 --- /dev/null +++ b/docs/Packets/C1-F5-02-DiscordIntegrationInfoRequest_by-client.md @@ -0,0 +1,18 @@ +# C1 F5 02 - DiscordIntegrationInfoRequest (by client) + +## Is sent when + +A client which supports the Discord integration requests how the server is connected to Discord. It's usually sent after the character entered the game world, and again when the player opens a Discord dialog. By sending it, the client also announces that it understands the ExternalChatMessage. + +## Causes the following actions on the server side + +The server sends a DiscordIntegrationInfo message, and from then on sends messages which were written in Discord as ExternalChatMessage. + +## Structure + +| Index | Length | Data Type | Value | Description | +|-------|--------|-----------|-------|-------------| +| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) | +| 1 | 1 | Byte | 4 | Packet header - length of the packet | +| 2 | 1 | Byte | 0xF5 | Packet header - packet type identifier | +| 3 | 1 | Byte | 0x02 | Packet header - sub packet type identifier | \ No newline at end of file diff --git a/docs/Packets/C1-F5-03-DiscordIntegrationInfo_by-server.md b/docs/Packets/C1-F5-03-DiscordIntegrationInfo_by-server.md new file mode 100644 index 0000000000..09868917bd --- /dev/null +++ b/docs/Packets/C1-F5-03-DiscordIntegrationInfo_by-server.md @@ -0,0 +1,27 @@ +# C1 F5 03 - DiscordIntegrationInfo (by server) + +## Is sent when + +After the client requested the Discord integration info, and after the player unlinked the account. + +## Causes the following actions on the client side + +The client configures its Discord features with it: the Rich Presence, a button which opens the invite, the account link dialog and the notice that the chat is mirrored. + +## Structure + +| Index | Length | Data Type | Value | Description | +|-------|--------|-----------|-------|-------------| +| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) | +| 1 | 1 | Byte | 224 | Packet header - length of the packet | +| 2 | 1 | Byte | 0xF5 | Packet header - packet type identifier | +| 3 | 1 | Byte | 0x03 | Packet header - sub packet type identifier | +| 4 | 1 | Boolean | | IsAccountLinked; Defines if the account is linked to a Discord user. | +| 5 | 1 | Boolean | | IsGuildChatBridged; Defines if the chat of the guild of the character is mirrored to a Discord channel. | +| 6 | 1 | Boolean | | IsAllianceChatBridged; Defines if the chat of the alliance of the character is mirrored to a Discord channel. | +| 7 | 1 | Boolean | | IsWorldChatBridged; Defines if the world chat is mirrored to a Discord channel. | +| 8 | 20 | String | | RichPresenceApplicationId; The id of the Discord application which the client uses for the Rich Presence. Empty, if the server has none. | +| 28 | 32 | String | | RichPresenceLargeImageKey; The key of the large image of the Rich Presence, as uploaded to the Discord application. Empty for no image. | +| 60 | 32 | String | | RichPresenceSmallImageKey; The key of the small image of the Rich Presence, as uploaded to the Discord application. Empty for no image. | +| 92 | 100 | String | | InviteUrl; The invite link to the Discord server of the game server, e.g. 'https://discord.gg/abc123'. Empty, if the server has none. | +| 192 | 32 | String | | LinkedUserName; The name of the Discord user which the account is linked to. Empty, if the account isn't linked. | \ No newline at end of file diff --git a/docs/Packets/C1-F5-04-DiscordLinkCodeRequest_by-client.md b/docs/Packets/C1-F5-04-DiscordLinkCodeRequest_by-client.md new file mode 100644 index 0000000000..54aa4cd66e --- /dev/null +++ b/docs/Packets/C1-F5-04-DiscordLinkCodeRequest_by-client.md @@ -0,0 +1,18 @@ +# C1 F5 04 - DiscordLinkCodeRequest (by client) + +## Is sent when + +The player wants to link the account to a Discord user, e.g. with a button of a Discord dialog. + +## Causes the following actions on the server side + +The server creates a one-time code and sends it with a DiscordLinkCode message. The player enters the code in Discord to complete the link. + +## Structure + +| Index | Length | Data Type | Value | Description | +|-------|--------|-----------|-------|-------------| +| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) | +| 1 | 1 | Byte | 4 | Packet header - length of the packet | +| 2 | 1 | Byte | 0xF5 | Packet header - packet type identifier | +| 3 | 1 | Byte | 0x04 | Packet header - sub packet type identifier | \ No newline at end of file diff --git a/docs/Packets/C1-F5-05-DiscordLinkCode_by-server.md b/docs/Packets/C1-F5-05-DiscordLinkCode_by-server.md new file mode 100644 index 0000000000..6e1704bc5d --- /dev/null +++ b/docs/Packets/C1-F5-05-DiscordLinkCode_by-server.md @@ -0,0 +1,30 @@ +# C1 F5 05 - DiscordLinkCode (by server) + +## Is sent when + +After the client requested a code to link the account to a Discord user. + +## Causes the following actions on the client side + +The client shows the code, so that the player can enter it in Discord. + +## Structure + +| Index | Length | Data Type | Value | Description | +|-------|--------|-----------|-------|-------------| +| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) | +| 1 | 1 | Byte | 16 | Packet header - length of the packet | +| 2 | 1 | Byte | 0xF5 | Packet header - packet type identifier | +| 3 | 1 | Byte | 0x05 | Packet header - sub packet type identifier | +| 4 | 1 | DiscordLinkCodeResult | | Result | +| 5 | 10 | String | | LinkCode; The one-time code, e.g. 'ABCD-EFGH'. The player enters it in Discord with '/link'. | +| 15 | 1 | Byte | | ValidMinutes; The number of minutes the code is valid. | + +### DiscordLinkCodeResult Enum + +The result of a request of a code to link the account to a Discord user. + +| Value | Name | Description | +|-------|------|-------------| +| 0 | Success | The code was created. | +| 1 | NotAvailable | Linking isn't available, e.g. because the server isn't connected to Discord. | \ No newline at end of file diff --git a/docs/Packets/C1-F5-06-DiscordUnlinkRequest_by-client.md b/docs/Packets/C1-F5-06-DiscordUnlinkRequest_by-client.md new file mode 100644 index 0000000000..a2c4c252d9 --- /dev/null +++ b/docs/Packets/C1-F5-06-DiscordUnlinkRequest_by-client.md @@ -0,0 +1,18 @@ +# C1 F5 06 - DiscordUnlinkRequest (by client) + +## Is sent when + +The player wants to remove the link of the account to a Discord user. + +## Causes the following actions on the server side + +The server removes the link and sends an updated DiscordIntegrationInfo message. + +## Structure + +| Index | Length | Data Type | Value | Description | +|-------|--------|-----------|-------|-------------| +| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) | +| 1 | 1 | Byte | 4 | Packet header - length of the packet | +| 2 | 1 | Byte | 0xF5 | Packet header - packet type identifier | +| 3 | 1 | Byte | 0x06 | Packet header - sub packet type identifier | \ No newline at end of file diff --git a/docs/Packets/C2-F5-07-ExternalChatMessage_by-server.md b/docs/Packets/C2-F5-07-ExternalChatMessage_by-server.md new file mode 100644 index 0000000000..c1afb263b3 --- /dev/null +++ b/docs/Packets/C2-F5-07-ExternalChatMessage_by-server.md @@ -0,0 +1,40 @@ +# C2 F5 07 - ExternalChatMessage (by server) + +## Is sent when + +A message was written outside of the game, e.g. in a Discord channel which is bound to the chat of a guild, an alliance or the world chat. It's only sent to clients which requested the DiscordIntegrationInfo before; other clients get it as normal chat message, with a prefix in front of the sender. + +## Causes the following actions on the client side + +The client shows the message in the chat of its scope, marked with its source, so that it can't be mistaken for a message of a character. + +## Structure + +| Index | Length | Data Type | Value | Description | +|-------|--------|-----------|-------|-------------| +| 0 | 1 | Byte | 0xC2 | [Packet type](PacketTypes.md) | +| 1 | 2 | Short | | Packet header - length of the packet | +| 3 | 1 | Byte | 0xF5 | Packet header - packet type identifier | +| 4 | 1 | Byte | 0x07 | Packet header - sub packet type identifier | +| 5 | 1 | ExternalChatSource | | Source | +| 6 | 1 | ExternalChatScope | | Scope | +| 7 | 48 | String | | SenderName; The name of the sender, without a prefix. It's not a character name, so it can't be whispered. | +| 55 | | String | | Message | + +### ExternalChatSource Enum + +Where an external chat message was written. + +| Value | Name | Description | +|-------|------|-------------| +| 0 | Discord | The message was written in Discord. | + +### ExternalChatScope Enum + +The chat of the game which an external chat message belongs to. + +| Value | Name | Description | +|-------|------|-------------| +| 0 | Guild | The chat of the guild. | +| 1 | Alliance | The chat of the alliance. | +| 2 | World | The world chat, which all players of all game servers can read. | \ No newline at end of file diff --git a/docs/Packets/ClientToServer.md b/docs/Packets/ClientToServer.md index 741eaf2412..c312883fd4 100644 --- a/docs/Packets/ClientToServer.md +++ b/docs/Packets/ClientToServer.md @@ -180,6 +180,9 @@ * [C1 F3 30 - SaveKeyConfiguration (by client)](C1-F3-30-SaveKeyConfiguration_by-client.md) * [C1 F3 52 - AddMasterSkillPoint (by client)](C1-F3-52-AddMasterSkillPoint_by-client.md) * [C1 F5 00 - ChatCommandListRequest (by client)](C1-F5-00-ChatCommandListRequest_by-client.md) + * [C1 F5 02 - DiscordIntegrationInfoRequest (by client)](C1-F5-02-DiscordIntegrationInfoRequest_by-client.md) + * [C1 F5 04 - DiscordLinkCodeRequest (by client)](C1-F5-04-DiscordLinkCodeRequest_by-client.md) + * [C1 F5 06 - DiscordUnlinkRequest (by client)](C1-F5-06-DiscordUnlinkRequest_by-client.md) * [C1 F6 0A - QuestSelectRequest (by client)](C1-F6-0A-QuestSelectRequest_by-client.md) * [C1 F6 0B - QuestProceedRequest (by client)](C1-F6-0B-QuestProceedRequest_by-client.md) * [C1 F6 0D - QuestCompletionRequest (by client)](C1-F6-0D-QuestCompletionRequest_by-client.md) diff --git a/docs/Packets/ServerToClient.md b/docs/Packets/ServerToClient.md index bb41254ab6..ab05396836 100644 --- a/docs/Packets/ServerToClient.md +++ b/docs/Packets/ServerToClient.md @@ -300,6 +300,9 @@ * [C1 F3 52 - MasterSkillLevelUpdate (by server)](C1-F3-52-MasterSkillLevelUpdate_by-server.md) * [C2 F3 53 - MasterSkillList (by server)](C2-F3-53-MasterSkillList_by-server.md) * [C2 F5 01 - AvailableChatCommand (by server)](C2-F5-01-AvailableChatCommand_by-server.md) + * [C1 F5 03 - DiscordIntegrationInfo (by server)](C1-F5-03-DiscordIntegrationInfo_by-server.md) + * [C1 F5 05 - DiscordLinkCode (by server)](C1-F5-05-DiscordLinkCode_by-server.md) + * [C2 F5 07 - ExternalChatMessage (by server)](C2-F5-07-ExternalChatMessage_by-server.md) * [C1 F6 03 - QuestEventResponse (by server)](C1-F6-03-QuestEventResponse_by-server.md) * [C1 F6 0A - AvailableQuests (by server)](C1-F6-0A-AvailableQuests_by-server.md) * [C1 F6 0B - QuestStepInfo (by server)](C1-F6-0B-QuestStepInfo_by-server.md) diff --git a/src/Network/Packets/ClientToServer/ClientToServerPackets.cs b/src/Network/Packets/ClientToServer/ClientToServerPackets.cs index e1e09e34a3..a3259faa8a 100644 --- a/src/Network/Packets/ClientToServer/ClientToServerPackets.cs +++ b/src/Network/Packets/ClientToServer/ClientToServerPackets.cs @@ -17590,6 +17590,237 @@ private ChatCommandListRequest(Memory data, bool initialize) /// The packet as byte span. public static implicit operator Memory(ChatCommandListRequest packet) => packet._data; } + + +/// +/// Is sent by the client when: A client which supports the Discord integration requests how the server is connected to Discord. It's usually sent after the character entered the game world, and again when the player opens a Discord dialog. By sending it, the client also announces that it understands the ExternalChatMessage. +/// Causes reaction on server side: The server sends a DiscordIntegrationInfo message, and from then on sends messages which were written in Discord as ExternalChatMessage. +/// +public readonly struct DiscordIntegrationInfoRequest +{ + private readonly Memory _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public DiscordIntegrationInfoRequest(Memory data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private DiscordIntegrationInfoRequest(Memory data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (byte)Math.Min(data.Length, Length); + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC1; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xF5; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x02; + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 4; + + /// + /// Gets the header of this packet. + /// + public C1HeaderWithSubCode Header => new (this._data); + + /// + /// Performs an implicit conversion from a Memory of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator DiscordIntegrationInfoRequest(Memory packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Memory of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Memory(DiscordIntegrationInfoRequest packet) => packet._data; +} + + +/// +/// Is sent by the client when: The player wants to link the account to a Discord user, e.g. with a button of a Discord dialog. +/// Causes reaction on server side: The server creates a one-time code and sends it with a DiscordLinkCode message. The player enters the code in Discord to complete the link. +/// +public readonly struct DiscordLinkCodeRequest +{ + private readonly Memory _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public DiscordLinkCodeRequest(Memory data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private DiscordLinkCodeRequest(Memory data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (byte)Math.Min(data.Length, Length); + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC1; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xF5; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x04; + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 4; + + /// + /// Gets the header of this packet. + /// + public C1HeaderWithSubCode Header => new (this._data); + + /// + /// Performs an implicit conversion from a Memory of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator DiscordLinkCodeRequest(Memory packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Memory of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Memory(DiscordLinkCodeRequest packet) => packet._data; +} + + +/// +/// Is sent by the client when: The player wants to remove the link of the account to a Discord user. +/// Causes reaction on server side: The server removes the link and sends an updated DiscordIntegrationInfo message. +/// +public readonly struct DiscordUnlinkRequest +{ + private readonly Memory _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public DiscordUnlinkRequest(Memory data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private DiscordUnlinkRequest(Memory data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (byte)Math.Min(data.Length, Length); + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC1; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xF5; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x06; + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 4; + + /// + /// Gets the header of this packet. + /// + public C1HeaderWithSubCode Header => new (this._data); + + /// + /// Performs an implicit conversion from a Memory of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator DiscordUnlinkRequest(Memory packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Memory of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Memory(DiscordUnlinkRequest packet) => packet._data; +} /// /// The state of the trade button. /// diff --git a/src/Network/Packets/ClientToServer/ClientToServerPackets.xml b/src/Network/Packets/ClientToServer/ClientToServerPackets.xml index 33e6a50ee7..8c65d97cc3 100644 --- a/src/Network/Packets/ClientToServer/ClientToServerPackets.xml +++ b/src/Network/Packets/ClientToServer/ClientToServerPackets.xml @@ -4265,6 +4265,42 @@ The server sends an AvailableChatCommand message for each available chat command. + + C1HeaderWithSubCode + F5 + 02 + DiscordIntegrationInfoRequest + Request Discord integration info + 4 + ClientToServer + A client which supports the Discord integration requests how the server is connected to Discord. It's usually sent after the character entered the game world, and again when the player opens a Discord dialog. By sending it, the client also announces that it understands the ExternalChatMessage. + The server sends a DiscordIntegrationInfo message, and from then on sends messages which were written in Discord as ExternalChatMessage. + + + + C1HeaderWithSubCode + F5 + 04 + DiscordLinkCodeRequest + Request Discord link code + 4 + ClientToServer + The player wants to link the account to a Discord user, e.g. with a button of a Discord dialog. + The server creates a one-time code and sends it with a DiscordLinkCode message. The player enters the code in Discord to complete the link. + + + + C1HeaderWithSubCode + F5 + 06 + DiscordUnlinkRequest + Request Discord unlink + 4 + ClientToServer + The player wants to remove the link of the account to a Discord user. + The server removes the link and sends an updated DiscordIntegrationInfo message. + + diff --git a/src/Network/Packets/ClientToServer/ClientToServerPacketsRef.cs b/src/Network/Packets/ClientToServer/ClientToServerPacketsRef.cs index 8bb115ffab..5629bfec86 100644 --- a/src/Network/Packets/ClientToServer/ClientToServerPacketsRef.cs +++ b/src/Network/Packets/ClientToServer/ClientToServerPacketsRef.cs @@ -17383,3 +17383,234 @@ private ChatCommandListRequestRef(Span data, bool initialize) /// The packet as byte span. public static implicit operator Span(ChatCommandListRequestRef packet) => packet._data; } + + +/// +/// Is sent by the client when: A client which supports the Discord integration requests how the server is connected to Discord. It's usually sent after the character entered the game world, and again when the player opens a Discord dialog. By sending it, the client also announces that it understands the ExternalChatMessage. +/// Causes reaction on server side: The server sends a DiscordIntegrationInfo message, and from then on sends messages which were written in Discord as ExternalChatMessage. +/// +public readonly ref struct DiscordIntegrationInfoRequestRef +{ + private readonly Span _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public DiscordIntegrationInfoRequestRef(Span data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private DiscordIntegrationInfoRequestRef(Span data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (byte)Math.Min(data.Length, Length); + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC1; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xF5; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x02; + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 4; + + /// + /// Gets the header of this packet. + /// + public C1HeaderWithSubCodeRef Header => new (this._data); + + /// + /// Performs an implicit conversion from a Span of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator DiscordIntegrationInfoRequestRef(Span packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Span of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Span(DiscordIntegrationInfoRequestRef packet) => packet._data; +} + + +/// +/// Is sent by the client when: The player wants to link the account to a Discord user, e.g. with a button of a Discord dialog. +/// Causes reaction on server side: The server creates a one-time code and sends it with a DiscordLinkCode message. The player enters the code in Discord to complete the link. +/// +public readonly ref struct DiscordLinkCodeRequestRef +{ + private readonly Span _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public DiscordLinkCodeRequestRef(Span data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private DiscordLinkCodeRequestRef(Span data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (byte)Math.Min(data.Length, Length); + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC1; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xF5; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x04; + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 4; + + /// + /// Gets the header of this packet. + /// + public C1HeaderWithSubCodeRef Header => new (this._data); + + /// + /// Performs an implicit conversion from a Span of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator DiscordLinkCodeRequestRef(Span packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Span of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Span(DiscordLinkCodeRequestRef packet) => packet._data; +} + + +/// +/// Is sent by the client when: The player wants to remove the link of the account to a Discord user. +/// Causes reaction on server side: The server removes the link and sends an updated DiscordIntegrationInfo message. +/// +public readonly ref struct DiscordUnlinkRequestRef +{ + private readonly Span _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public DiscordUnlinkRequestRef(Span data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private DiscordUnlinkRequestRef(Span data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (byte)Math.Min(data.Length, Length); + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC1; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xF5; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x06; + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 4; + + /// + /// Gets the header of this packet. + /// + public C1HeaderWithSubCodeRef Header => new (this._data); + + /// + /// Performs an implicit conversion from a Span of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator DiscordUnlinkRequestRef(Span packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Span of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Span(DiscordUnlinkRequestRef packet) => packet._data; +} diff --git a/src/Network/Packets/ClientToServer/ConnectionExtensions.cs b/src/Network/Packets/ClientToServer/ConnectionExtensions.cs index ce289c8f9f..99a3ae6df0 100644 --- a/src/Network/Packets/ClientToServer/ConnectionExtensions.cs +++ b/src/Network/Packets/ClientToServer/ConnectionExtensions.cs @@ -5571,5 +5571,80 @@ int WritePacket() return packet.Header.Length; } + await connection.SendAsync(WritePacket).ConfigureAwait(false); + } + + /// + /// Sends a to this connection. + /// + /// The connection. + /// + /// Is sent by the client when: A client which supports the Discord integration requests how the server is connected to Discord. It's usually sent after the character entered the game world, and again when the player opens a Discord dialog. By sending it, the client also announces that it understands the ExternalChatMessage. + /// Causes reaction on server side: The server sends a DiscordIntegrationInfo message, and from then on sends messages which were written in Discord as ExternalChatMessage. + /// + public static async ValueTask SendDiscordIntegrationInfoRequestAsync(this IConnection? connection) + { + if (connection is null) + { + return; + } + + int WritePacket() + { + var length = DiscordIntegrationInfoRequestRef.Length; + var packet = new DiscordIntegrationInfoRequestRef(connection.Output.GetSpan(length)[..length]); + return packet.Header.Length; + } + + await connection.SendAsync(WritePacket).ConfigureAwait(false); + } + + /// + /// Sends a to this connection. + /// + /// The connection. + /// + /// Is sent by the client when: The player wants to link the account to a Discord user, e.g. with a button of a Discord dialog. + /// Causes reaction on server side: The server creates a one-time code and sends it with a DiscordLinkCode message. The player enters the code in Discord to complete the link. + /// + public static async ValueTask SendDiscordLinkCodeRequestAsync(this IConnection? connection) + { + if (connection is null) + { + return; + } + + int WritePacket() + { + var length = DiscordLinkCodeRequestRef.Length; + var packet = new DiscordLinkCodeRequestRef(connection.Output.GetSpan(length)[..length]); + return packet.Header.Length; + } + + await connection.SendAsync(WritePacket).ConfigureAwait(false); + } + + /// + /// Sends a to this connection. + /// + /// The connection. + /// + /// Is sent by the client when: The player wants to remove the link of the account to a Discord user. + /// Causes reaction on server side: The server removes the link and sends an updated DiscordIntegrationInfo message. + /// + public static async ValueTask SendDiscordUnlinkRequestAsync(this IConnection? connection) + { + if (connection is null) + { + return; + } + + int WritePacket() + { + var length = DiscordUnlinkRequestRef.Length; + var packet = new DiscordUnlinkRequestRef(connection.Output.GetSpan(length)[..length]); + return packet.Header.Length; + } + await connection.SendAsync(WritePacket).ConfigureAwait(false); }} \ No newline at end of file diff --git a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs index 89fb20a477..5a7d3f03c2 100644 --- a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs +++ b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs @@ -6634,6 +6634,116 @@ int WritePacket() await connection.SendAsync(WritePacket).ConfigureAwait(false); } + /// + /// Sends a to this connection. + /// + /// The connection. + /// Defines if the account is linked to a Discord user. + /// Defines if the chat of the guild of the character is mirrored to a Discord channel. + /// Defines if the chat of the alliance of the character is mirrored to a Discord channel. + /// Defines if the world chat is mirrored to a Discord channel. + /// The id of the Discord application which the client uses for the Rich Presence. Empty, if the server has none. + /// The key of the large image of the Rich Presence, as uploaded to the Discord application. Empty for no image. + /// The key of the small image of the Rich Presence, as uploaded to the Discord application. Empty for no image. + /// The invite link to the Discord server of the game server, e.g. 'https://discord.gg/abc123'. Empty, if the server has none. + /// The name of the Discord user which the account is linked to. Empty, if the account isn't linked. + /// + /// Is sent by the server when: After the client requested the Discord integration info, and after the player unlinked the account. + /// Causes reaction on client side: The client configures its Discord features with it: the Rich Presence, a button which opens the invite, the account link dialog and the notice that the chat is mirrored. + /// + public static async ValueTask SendDiscordIntegrationInfoAsync(this IConnection? connection, bool @isAccountLinked, bool @isGuildChatBridged, bool @isAllianceChatBridged, bool @isWorldChatBridged, string @richPresenceApplicationId, string @richPresenceLargeImageKey, string @richPresenceSmallImageKey, string @inviteUrl, string @linkedUserName) + { + if (connection is null) + { + return; + } + + int WritePacket() + { + var length = DiscordIntegrationInfoRef.Length; + var packet = new DiscordIntegrationInfoRef(connection.Output.GetSpan(length)[..length]); + packet.IsAccountLinked = @isAccountLinked; + packet.IsGuildChatBridged = @isGuildChatBridged; + packet.IsAllianceChatBridged = @isAllianceChatBridged; + packet.IsWorldChatBridged = @isWorldChatBridged; + packet.RichPresenceApplicationId = @richPresenceApplicationId; + packet.RichPresenceLargeImageKey = @richPresenceLargeImageKey; + packet.RichPresenceSmallImageKey = @richPresenceSmallImageKey; + packet.InviteUrl = @inviteUrl; + packet.LinkedUserName = @linkedUserName; + + return packet.Header.Length; + } + + await connection.SendAsync(WritePacket).ConfigureAwait(false); + } + + /// + /// Sends a to this connection. + /// + /// The connection. + /// The result. + /// The one-time code, e.g. 'ABCD-EFGH'. The player enters it in Discord with '/link'. + /// The number of minutes the code is valid. + /// + /// Is sent by the server when: After the client requested a code to link the account to a Discord user. + /// Causes reaction on client side: The client shows the code, so that the player can enter it in Discord. + /// + public static async ValueTask SendDiscordLinkCodeAsync(this IConnection? connection, DiscordLinkCode.DiscordLinkCodeResult @result, string @linkCode, byte @validMinutes) + { + if (connection is null) + { + return; + } + + int WritePacket() + { + var length = DiscordLinkCodeRef.Length; + var packet = new DiscordLinkCodeRef(connection.Output.GetSpan(length)[..length]); + packet.Result = @result; + packet.LinkCode = @linkCode; + packet.ValidMinutes = @validMinutes; + + return packet.Header.Length; + } + + await connection.SendAsync(WritePacket).ConfigureAwait(false); + } + + /// + /// Sends a to this connection. + /// + /// The connection. + /// The source. + /// The scope. + /// The name of the sender, without a prefix. It's not a character name, so it can't be whispered. + /// The message. + /// + /// Is sent by the server when: A message was written outside of the game, e.g. in a Discord channel which is bound to the chat of a guild, an alliance or the world chat. It's only sent to clients which requested the DiscordIntegrationInfo before; other clients get it as normal chat message, with a prefix in front of the sender. + /// Causes reaction on client side: The client shows the message in the chat of its scope, marked with its source, so that it can't be mistaken for a message of a character. + /// + public static async ValueTask SendExternalChatMessageAsync(this IConnection? connection, ExternalChatMessage.ExternalChatSource @source, ExternalChatMessage.ExternalChatScope @scope, string @senderName, string @message) + { + if (connection is null) + { + return; + } + + int WritePacket() + { + var length = ExternalChatMessageRef.GetRequiredSize(message); + var packet = new ExternalChatMessageRef(connection.Output.GetSpan(length)[..length]); + packet.Source = @source; + packet.Scope = @scope; + packet.SenderName = @senderName; + packet.Message = @message; + + return packet.Header.Length; + } + + await connection.SendAsync(WritePacket).ConfigureAwait(false); + } + /// /// Sends a to this connection. /// diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs index f9286bd864..b7603ff735 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs @@ -32546,6 +32546,436 @@ public string ValidValues } +/// +/// Is sent by the server when: After the client requested the Discord integration info, and after the player unlinked the account. +/// Causes reaction on client side: The client configures its Discord features with it: the Rich Presence, a button which opens the invite, the account link dialog and the notice that the chat is mirrored. +/// +public readonly struct DiscordIntegrationInfo +{ + private readonly Memory _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public DiscordIntegrationInfo(Memory data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private DiscordIntegrationInfo(Memory data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (byte)Math.Min(data.Length, Length); + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC1; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xF5; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x03; + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 224; + + /// + /// Gets the header of this packet. + /// + public C1HeaderWithSubCode Header => new (this._data); + + /// + /// Gets or sets defines if the account is linked to a Discord user. + /// + public bool IsAccountLinked + { + get => this._data.Span[4..].GetBoolean(); + set => this._data.Span[4..].SetBoolean(value); + } + + /// + /// Gets or sets defines if the chat of the guild of the character is mirrored to a Discord channel. + /// + public bool IsGuildChatBridged + { + get => this._data.Span[5..].GetBoolean(); + set => this._data.Span[5..].SetBoolean(value); + } + + /// + /// Gets or sets defines if the chat of the alliance of the character is mirrored to a Discord channel. + /// + public bool IsAllianceChatBridged + { + get => this._data.Span[6..].GetBoolean(); + set => this._data.Span[6..].SetBoolean(value); + } + + /// + /// Gets or sets defines if the world chat is mirrored to a Discord channel. + /// + public bool IsWorldChatBridged + { + get => this._data.Span[7..].GetBoolean(); + set => this._data.Span[7..].SetBoolean(value); + } + + /// + /// Gets or sets the id of the Discord application which the client uses for the Rich Presence. Empty, if the server has none. + /// + public string RichPresenceApplicationId + { + get => this._data.Span.ExtractString(8, 20, System.Text.Encoding.UTF8); + set => this._data.Slice(8, 20).Span.WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Gets or sets the key of the large image of the Rich Presence, as uploaded to the Discord application. Empty for no image. + /// + public string RichPresenceLargeImageKey + { + get => this._data.Span.ExtractString(28, 32, System.Text.Encoding.UTF8); + set => this._data.Slice(28, 32).Span.WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Gets or sets the key of the small image of the Rich Presence, as uploaded to the Discord application. Empty for no image. + /// + public string RichPresenceSmallImageKey + { + get => this._data.Span.ExtractString(60, 32, System.Text.Encoding.UTF8); + set => this._data.Slice(60, 32).Span.WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Gets or sets the invite link to the Discord server of the game server, e.g. 'https://discord.gg/abc123'. Empty, if the server has none. + /// + public string InviteUrl + { + get => this._data.Span.ExtractString(92, 100, System.Text.Encoding.UTF8); + set => this._data.Slice(92, 100).Span.WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Gets or sets the name of the Discord user which the account is linked to. Empty, if the account isn't linked. + /// + public string LinkedUserName + { + get => this._data.Span.ExtractString(192, 32, System.Text.Encoding.UTF8); + set => this._data.Slice(192, 32).Span.WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Performs an implicit conversion from a Memory of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator DiscordIntegrationInfo(Memory packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Memory of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Memory(DiscordIntegrationInfo packet) => packet._data; +} + + +/// +/// Is sent by the server when: After the client requested a code to link the account to a Discord user. +/// Causes reaction on client side: The client shows the code, so that the player can enter it in Discord. +/// +public readonly struct DiscordLinkCode +{ + /// + /// The result of a request of a code to link the account to a Discord user. + /// + public enum DiscordLinkCodeResult + { + /// + /// The code was created. + /// + Success = 0, + + /// + /// Linking isn't available, e.g. because the server isn't connected to Discord. + /// + NotAvailable = 1, + } + + private readonly Memory _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public DiscordLinkCode(Memory data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private DiscordLinkCode(Memory data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (byte)Math.Min(data.Length, Length); + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC1; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xF5; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x05; + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 16; + + /// + /// Gets the header of this packet. + /// + public C1HeaderWithSubCode Header => new (this._data); + + /// + /// Gets or sets the result. + /// + public DiscordLinkCode.DiscordLinkCodeResult Result + { + get => (DiscordLinkCodeResult)this._data.Span[4]; + set => this._data.Span[4] = (byte)value; + } + + /// + /// Gets or sets the one-time code, e.g. 'ABCD-EFGH'. The player enters it in Discord with '/link'. + /// + public string LinkCode + { + get => this._data.Span.ExtractString(5, 10, System.Text.Encoding.UTF8); + set => this._data.Slice(5, 10).Span.WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Gets or sets the number of minutes the code is valid. + /// + public byte ValidMinutes + { + get => this._data.Span[15]; + set => this._data.Span[15] = value; + } + + /// + /// Performs an implicit conversion from a Memory of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator DiscordLinkCode(Memory packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Memory of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Memory(DiscordLinkCode packet) => packet._data; +} + + +/// +/// Is sent by the server when: A message was written outside of the game, e.g. in a Discord channel which is bound to the chat of a guild, an alliance or the world chat. It's only sent to clients which requested the DiscordIntegrationInfo before; other clients get it as normal chat message, with a prefix in front of the sender. +/// Causes reaction on client side: The client shows the message in the chat of its scope, marked with its source, so that it can't be mistaken for a message of a character. +/// +public readonly struct ExternalChatMessage +{ + /// + /// Where an external chat message was written. + /// + public enum ExternalChatSource + { + /// + /// The message was written in Discord. + /// + Discord = 0, + } + + /// + /// The chat of the game which an external chat message belongs to. + /// + public enum ExternalChatScope + { + /// + /// The chat of the guild. + /// + Guild = 0, + + /// + /// The chat of the alliance. + /// + Alliance = 1, + + /// + /// The world chat, which all players of all game servers can read. + /// + World = 2, + } + + private readonly Memory _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public ExternalChatMessage(Memory data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private ExternalChatMessage(Memory data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (ushort)data.Length; + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC2; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xF5; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x07; + + /// + /// Gets the header of this packet. + /// + public C2HeaderWithSubCode Header => new (this._data); + + /// + /// Gets or sets the source. + /// + public ExternalChatMessage.ExternalChatSource Source + { + get => (ExternalChatSource)this._data.Span[5]; + set => this._data.Span[5] = (byte)value; + } + + /// + /// Gets or sets the scope. + /// + public ExternalChatMessage.ExternalChatScope Scope + { + get => (ExternalChatScope)this._data.Span[6]; + set => this._data.Span[6] = (byte)value; + } + + /// + /// Gets or sets the name of the sender, without a prefix. It's not a character name, so it can't be whispered. + /// + public string SenderName + { + get => this._data.Span.ExtractString(7, 48, System.Text.Encoding.UTF8); + set => this._data.Slice(7, 48).Span.WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Gets or sets the message. + /// + public string Message + { + get => this._data.Span.ExtractString(55, this._data.Length - 55, System.Text.Encoding.UTF8); + set => this._data.Slice(55).Span.WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Performs an implicit conversion from a Memory of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator ExternalChatMessage(Memory packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Memory of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Memory(ExternalChatMessage packet) => packet._data; + + /// + /// Calculates the size of the packet for the specified field content. + /// + /// The content of the variable 'Message' field from which the size will be calculated. + public static int GetRequiredSize(string content) => System.Text.Encoding.UTF8.GetByteCount(content) + 1 + 55; + + /// + /// Calculates the size of the packet for the specified field content. + /// + /// The content length in bytes of the variable 'Message' field from which the size will be calculated. + public static int GetRequiredSize(int contentLength) => contentLength + 1 + 55; +} + + /// /// Is sent by the server when: The player receives the result of registering Rena or Event Chips at the Golden Archer NPC. /// Causes reaction on client side: The client updates the Golden Archer interface with total registered count and remaining count in inventory. diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml index 8378cc7cba..42aa684727 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml @@ -11839,6 +11839,195 @@ + + C1HeaderWithSubCode + F5 + 03 + DiscordIntegrationInfo + 224 + ServerToClient + After the client requested the Discord integration info, and after the player unlinked the account. + The client configures its Discord features with it: the Rich Presence, a button which opens the invite, the account link dialog and the notice that the chat is mirrored. + + + 4 + Boolean + IsAccountLinked + Defines if the account is linked to a Discord user. + + + 5 + Boolean + IsGuildChatBridged + Defines if the chat of the guild of the character is mirrored to a Discord channel. + + + 6 + Boolean + IsAllianceChatBridged + Defines if the chat of the alliance of the character is mirrored to a Discord channel. + + + 7 + Boolean + IsWorldChatBridged + Defines if the world chat is mirrored to a Discord channel. + + + 8 + String + RichPresenceApplicationId + 20 + The id of the Discord application which the client uses for the Rich Presence. Empty, if the server has none. + + + 28 + String + RichPresenceLargeImageKey + 32 + The key of the large image of the Rich Presence, as uploaded to the Discord application. Empty for no image. + + + 60 + String + RichPresenceSmallImageKey + 32 + The key of the small image of the Rich Presence, as uploaded to the Discord application. Empty for no image. + + + 92 + String + InviteUrl + 100 + The invite link to the Discord server of the game server, e.g. 'https://discord.gg/abc123'. Empty, if the server has none. + + + 192 + String + LinkedUserName + 32 + The name of the Discord user which the account is linked to. Empty, if the account isn't linked. + + + + + C1HeaderWithSubCode + F5 + 05 + DiscordLinkCode + 16 + ServerToClient + After the client requested a code to link the account to a Discord user. + The client shows the code, so that the player can enter it in Discord. + + + 4 + Enum + DiscordLinkCodeResult + Result + + + 5 + String + LinkCode + 10 + The one-time code, e.g. 'ABCD-EFGH'. The player enters it in Discord with '/link'. + + + 15 + Byte + ValidMinutes + The number of minutes the code is valid. + + + + + DiscordLinkCodeResult + The result of a request of a code to link the account to a Discord user. + + + Success + The code was created. + 0 + + + NotAvailable + Linking isn't available, e.g. because the server isn't connected to Discord. + 1 + + + + + + + C2HeaderWithSubCode + F5 + 07 + ExternalChatMessage + ServerToClient + A message was written outside of the game, e.g. in a Discord channel which is bound to the chat of a guild, an alliance or the world chat. It's only sent to clients which requested the DiscordIntegrationInfo before; other clients get it as normal chat message, with a prefix in front of the sender. + The client shows the message in the chat of its scope, marked with its source, so that it can't be mistaken for a message of a character. + + + 5 + Enum + ExternalChatSource + Source + + + 6 + Enum + ExternalChatScope + Scope + + + 7 + String + SenderName + 48 + The name of the sender, without a prefix. It's not a character name, so it can't be whispered. + + + 55 + String + Message + + + + + ExternalChatSource + Where an external chat message was written. + + + Discord + The message was written in Discord. + 0 + + + + + ExternalChatScope + The chat of the game which an external chat message belongs to. + + + Guild + The chat of the guild. + 0 + + + Alliance + The chat of the alliance. + 1 + + + World + The world chat, which all players of all game servers can read. + 2 + + + + + C1Header 94 diff --git a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs index c1af132e94..7a9b7c7025 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs +++ b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs @@ -30667,6 +30667,388 @@ public string ValidValues } +/// +/// Is sent by the server when: After the client requested the Discord integration info, and after the player unlinked the account. +/// Causes reaction on client side: The client configures its Discord features with it: the Rich Presence, a button which opens the invite, the account link dialog and the notice that the chat is mirrored. +/// +public readonly ref struct DiscordIntegrationInfoRef +{ + private readonly Span _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public DiscordIntegrationInfoRef(Span data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private DiscordIntegrationInfoRef(Span data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (byte)Math.Min(data.Length, Length); + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC1; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xF5; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x03; + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 224; + + /// + /// Gets the header of this packet. + /// + public C1HeaderWithSubCodeRef Header => new (this._data); + + /// + /// Gets or sets defines if the account is linked to a Discord user. + /// + public bool IsAccountLinked + { + get => this._data[4..].GetBoolean(); + set => this._data[4..].SetBoolean(value); + } + + /// + /// Gets or sets defines if the chat of the guild of the character is mirrored to a Discord channel. + /// + public bool IsGuildChatBridged + { + get => this._data[5..].GetBoolean(); + set => this._data[5..].SetBoolean(value); + } + + /// + /// Gets or sets defines if the chat of the alliance of the character is mirrored to a Discord channel. + /// + public bool IsAllianceChatBridged + { + get => this._data[6..].GetBoolean(); + set => this._data[6..].SetBoolean(value); + } + + /// + /// Gets or sets defines if the world chat is mirrored to a Discord channel. + /// + public bool IsWorldChatBridged + { + get => this._data[7..].GetBoolean(); + set => this._data[7..].SetBoolean(value); + } + + /// + /// Gets or sets the id of the Discord application which the client uses for the Rich Presence. Empty, if the server has none. + /// + public string RichPresenceApplicationId + { + get => this._data.ExtractString(8, 20, System.Text.Encoding.UTF8); + set => this._data.Slice(8, 20).WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Gets or sets the key of the large image of the Rich Presence, as uploaded to the Discord application. Empty for no image. + /// + public string RichPresenceLargeImageKey + { + get => this._data.ExtractString(28, 32, System.Text.Encoding.UTF8); + set => this._data.Slice(28, 32).WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Gets or sets the key of the small image of the Rich Presence, as uploaded to the Discord application. Empty for no image. + /// + public string RichPresenceSmallImageKey + { + get => this._data.ExtractString(60, 32, System.Text.Encoding.UTF8); + set => this._data.Slice(60, 32).WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Gets or sets the invite link to the Discord server of the game server, e.g. 'https://discord.gg/abc123'. Empty, if the server has none. + /// + public string InviteUrl + { + get => this._data.ExtractString(92, 100, System.Text.Encoding.UTF8); + set => this._data.Slice(92, 100).WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Gets or sets the name of the Discord user which the account is linked to. Empty, if the account isn't linked. + /// + public string LinkedUserName + { + get => this._data.ExtractString(192, 32, System.Text.Encoding.UTF8); + set => this._data.Slice(192, 32).WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Performs an implicit conversion from a Span of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator DiscordIntegrationInfoRef(Span packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Span of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Span(DiscordIntegrationInfoRef packet) => packet._data; +} + + +/// +/// Is sent by the server when: After the client requested a code to link the account to a Discord user. +/// Causes reaction on client side: The client shows the code, so that the player can enter it in Discord. +/// +public readonly ref struct DiscordLinkCodeRef +{ + private readonly Span _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public DiscordLinkCodeRef(Span data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private DiscordLinkCodeRef(Span data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (byte)Math.Min(data.Length, Length); + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC1; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xF5; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x05; + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 16; + + /// + /// Gets the header of this packet. + /// + public C1HeaderWithSubCodeRef Header => new (this._data); + + /// + /// Gets or sets the result. + /// + public DiscordLinkCode.DiscordLinkCodeResult Result + { + get => (DiscordLinkCode.DiscordLinkCodeResult)this._data[4]; + set => this._data[4] = (byte)value; + } + + /// + /// Gets or sets the one-time code, e.g. 'ABCD-EFGH'. The player enters it in Discord with '/link'. + /// + public string LinkCode + { + get => this._data.ExtractString(5, 10, System.Text.Encoding.UTF8); + set => this._data.Slice(5, 10).WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Gets or sets the number of minutes the code is valid. + /// + public byte ValidMinutes + { + get => this._data[15]; + set => this._data[15] = value; + } + + /// + /// Performs an implicit conversion from a Span of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator DiscordLinkCodeRef(Span packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Span of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Span(DiscordLinkCodeRef packet) => packet._data; +} + + +/// +/// Is sent by the server when: A message was written outside of the game, e.g. in a Discord channel which is bound to the chat of a guild, an alliance or the world chat. It's only sent to clients which requested the DiscordIntegrationInfo before; other clients get it as normal chat message, with a prefix in front of the sender. +/// Causes reaction on client side: The client shows the message in the chat of its scope, marked with its source, so that it can't be mistaken for a message of a character. +/// +public readonly ref struct ExternalChatMessageRef +{ + private readonly Span _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public ExternalChatMessageRef(Span data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private ExternalChatMessageRef(Span data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (ushort)data.Length; + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC2; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xF5; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x07; + + /// + /// Gets the header of this packet. + /// + public C2HeaderWithSubCodeRef Header => new (this._data); + + /// + /// Gets or sets the source. + /// + public ExternalChatMessage.ExternalChatSource Source + { + get => (ExternalChatMessage.ExternalChatSource)this._data[5]; + set => this._data[5] = (byte)value; + } + + /// + /// Gets or sets the scope. + /// + public ExternalChatMessage.ExternalChatScope Scope + { + get => (ExternalChatMessage.ExternalChatScope)this._data[6]; + set => this._data[6] = (byte)value; + } + + /// + /// Gets or sets the name of the sender, without a prefix. It's not a character name, so it can't be whispered. + /// + public string SenderName + { + get => this._data.ExtractString(7, 48, System.Text.Encoding.UTF8); + set => this._data.Slice(7, 48).WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Gets or sets the message. + /// + public string Message + { + get => this._data.ExtractString(55, this._data.Length - 55, System.Text.Encoding.UTF8); + set => this._data.Slice(55).WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Performs an implicit conversion from a Span of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator ExternalChatMessageRef(Span packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Span of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Span(ExternalChatMessageRef packet) => packet._data; + + /// + /// Calculates the size of the packet for the specified field content. + /// + /// The content of the variable 'Message' field from which the size will be calculated. + public static int GetRequiredSize(string content) => System.Text.Encoding.UTF8.GetByteCount(content) + 1 + 55; + + /// + /// Calculates the size of the packet for the specified field content. + /// + /// The content length in bytes of the variable 'Message' field from which the size will be calculated. + public static int GetRequiredSize(int contentLength) => contentLength + 1 + 55; +} + + /// /// Is sent by the server when: The player receives the result of registering Rena or Event Chips at the Golden Archer NPC. /// Causes reaction on client side: The client updates the Golden Archer interface with total registered count and remaining count in inventory. diff --git a/tests/MUnique.OpenMU.Network.Packets.Tests/ClientToServerPacketTests.cs b/tests/MUnique.OpenMU.Network.Packets.Tests/ClientToServerPacketTests.cs index 96b5119c73..8a8fe9f4e4 100644 --- a/tests/MUnique.OpenMU.Network.Packets.Tests/ClientToServerPacketTests.cs +++ b/tests/MUnique.OpenMU.Network.Packets.Tests/ClientToServerPacketTests.cs @@ -3919,4 +3919,46 @@ public void ChatCommandListRequest_PacketSizeValidation() Assert.That(actualLength, Is.EqualTo(expectedLength), "Packet length mismatch: declared length does not match calculated size"); } + + /// + /// Tests the packet size calculation for DiscordIntegrationInfoRequest. + /// + [Test] + public void DiscordIntegrationInfoRequest_PacketSizeValidation() + { + // Fixed-length packet validation + const int expectedLength = 4; + var actualLength = DiscordIntegrationInfoRequestRef.Length; + + Assert.That(actualLength, Is.EqualTo(expectedLength), + "Packet length mismatch: declared length does not match calculated size"); + } + + /// + /// Tests the packet size calculation for DiscordLinkCodeRequest. + /// + [Test] + public void DiscordLinkCodeRequest_PacketSizeValidation() + { + // Fixed-length packet validation + const int expectedLength = 4; + var actualLength = DiscordLinkCodeRequestRef.Length; + + Assert.That(actualLength, Is.EqualTo(expectedLength), + "Packet length mismatch: declared length does not match calculated size"); + } + + /// + /// Tests the packet size calculation for DiscordUnlinkRequest. + /// + [Test] + public void DiscordUnlinkRequest_PacketSizeValidation() + { + // Fixed-length packet validation + const int expectedLength = 4; + var actualLength = DiscordUnlinkRequestRef.Length; + + Assert.That(actualLength, Is.EqualTo(expectedLength), + "Packet length mismatch: declared length does not match calculated size"); + } } diff --git a/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs b/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs index 045072327e..2bb8d73616 100644 --- a/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs +++ b/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs @@ -6712,6 +6712,98 @@ public void AvailableChatCommand_PacketSizeValidation() "Field 'Description' has invalid negative index"); } + /// + /// Tests the packet size calculation for DiscordIntegrationInfo. + /// + [Test] + public void DiscordIntegrationInfo_PacketSizeValidation() + { + // Fixed-length packet validation + const int expectedLength = 224; + var actualLength = DiscordIntegrationInfoRef.Length; + + Assert.That(actualLength, Is.EqualTo(expectedLength), + "Packet length mismatch: declared length does not match calculated size"); + + // Validate field 'IsAccountLinked' boundary + Assert.That(4 + 1, Is.LessThanOrEqualTo(expectedLength), + "Field 'IsAccountLinked' exceeds packet boundary"); + + // Validate field 'IsGuildChatBridged' boundary + Assert.That(5 + 1, Is.LessThanOrEqualTo(expectedLength), + "Field 'IsGuildChatBridged' exceeds packet boundary"); + + // Validate field 'IsAllianceChatBridged' boundary + Assert.That(6 + 1, Is.LessThanOrEqualTo(expectedLength), + "Field 'IsAllianceChatBridged' exceeds packet boundary"); + + // Validate field 'IsWorldChatBridged' boundary + Assert.That(7 + 1, Is.LessThanOrEqualTo(expectedLength), + "Field 'IsWorldChatBridged' exceeds packet boundary"); + + // Validate field 'RichPresenceApplicationId' boundary + Assert.That(8 + 20, Is.LessThanOrEqualTo(expectedLength), + "Field 'RichPresenceApplicationId' exceeds packet boundary"); + + // Validate field 'RichPresenceLargeImageKey' boundary + Assert.That(28 + 32, Is.LessThanOrEqualTo(expectedLength), + "Field 'RichPresenceLargeImageKey' exceeds packet boundary"); + + // Validate field 'RichPresenceSmallImageKey' boundary + Assert.That(60 + 32, Is.LessThanOrEqualTo(expectedLength), + "Field 'RichPresenceSmallImageKey' exceeds packet boundary"); + + // Validate field 'InviteUrl' boundary + Assert.That(92 + 100, Is.LessThanOrEqualTo(expectedLength), + "Field 'InviteUrl' exceeds packet boundary"); + + // Validate field 'LinkedUserName' boundary + Assert.That(192 + 32, Is.LessThanOrEqualTo(expectedLength), + "Field 'LinkedUserName' exceeds packet boundary"); + } + + /// + /// Tests the packet size calculation for DiscordLinkCode. + /// + [Test] + public void DiscordLinkCode_PacketSizeValidation() + { + // Fixed-length packet validation + const int expectedLength = 16; + var actualLength = DiscordLinkCodeRef.Length; + + Assert.That(actualLength, Is.EqualTo(expectedLength), + "Packet length mismatch: declared length does not match calculated size"); + + // Validate field 'Result' boundary + Assert.That(4 + 1, Is.LessThanOrEqualTo(expectedLength), + "Field 'Result' exceeds packet boundary"); + + // Validate field 'LinkCode' boundary + Assert.That(5 + 10, Is.LessThanOrEqualTo(expectedLength), + "Field 'LinkCode' exceeds packet boundary"); + + // Validate field 'ValidMinutes' boundary + Assert.That(15 + 1, Is.LessThanOrEqualTo(expectedLength), + "Field 'ValidMinutes' exceeds packet boundary"); + } + + /// + /// Tests the packet size calculation for ExternalChatMessage. + /// + [Test] + public void ExternalChatMessage_PacketSizeValidation() + { + // Variable-length packet validation + // Test GetRequiredSize method with sample data + const string testString = "TestData"; + var calculatedSize = ExternalChatMessageRef.GetRequiredSize(testString); + var expectedMinSize = Encoding.UTF8.GetByteCount(testString) + 1 + 55; + + Assert.That(calculatedSize, Is.GreaterThanOrEqualTo(expectedMinSize), + "GetRequiredSize calculation incorrect for string field"); + } + /// /// Tests the packet size calculation for EventChipRegistrationResult. /// From b8e4dfeeb51aba71991e8cd935e3db110d6f5b82 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:53:35 +0000 Subject: [PATCH 2/3] feat(discord): serve the Discord integration packets to MuMain Server side of the new F5 packets: - New feature plugin "Discord integration of the client" with the values the client gets: invite link, Rich Presence application id and image keys. Deactivated, the clients get no integration info. - DiscordIntegrationAction collects the info for a player (configuration, account link, guild and alliance chat bindings, published world chat), creates link codes and unlinks. /discord unlink uses the same unlink. - Handlers for DiscordIntegrationInfoRequest, DiscordLinkCodeRequest and DiscordUnlinkRequest, and the DiscordIntegrationViewPlugIn which sends the answers. - Messages of senders bridged from Discord (the '@' prefix, now shared as BridgedChatSender) go to clients which asked for the integration info as ExternalChatMessage: guild and alliance chat in ChatViewPlugIn, world chat in GameServer. Other clients keep getting them as before. - Docs: the game client section of the Discord page and the plugin page. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012feEciycvL8PGjsezyRFqj --- docs-website/docs/admin-panel/plugins.md | 9 + docs-website/docs/server-features/discord.md | 27 +++ src/Discord/ChatBridge/DiscordChatText.cs | 3 +- src/GameLogic/Discord/BridgedChatSender.cs | 32 +++ .../DiscordIntegrationConfiguration.cs | 34 +++ .../DiscordIntegrationFeaturePlugIn.cs | 40 ++++ .../Discord/DiscordIntegrationInfo.cs | 26 +++ src/GameLogic/Discord/ExternalChatScope.cs | 26 +++ .../Discord/DiscordIntegrationAction.cs | 152 +++++++++++++ .../ChatCommands/DiscordChatCommandPlugIn.cs | 11 +- .../Properties/PlugInResources.Designer.cs | 18 ++ .../Properties/PlugInResources.de.resx | 6 + src/GameLogic/Properties/PlugInResources.resx | 6 + .../Views/IDiscordIntegrationViewPlugIn.cs | 36 ++++ src/GameServer/GameServer.cs | 19 +- ...cordIntegrationInfoRequestHandlerPlugIn.cs | 42 ++++ .../DiscordLinkCodeRequestHandlerPlugIn.cs | 42 ++++ .../DiscordUnlinkRequestHandlerPlugIn.cs | 42 ++++ .../Properties/PlugInResources.Designer.cs | 72 +++++++ .../Properties/PlugInResources.de.resx | 24 +++ .../Properties/PlugInResources.resx | 24 +++ src/GameServer/RemoteView/ChatViewPlugIn.cs | 40 +++- .../DiscordIntegrationViewPlugIn.cs | 86 ++++++++ .../Discord/DiscordIntegrationActionTest.cs | 82 +++++++ .../DiscordIntegrationViewPlugInTest.cs | 203 ++++++++++++++++++ 25 files changed, 1090 insertions(+), 12 deletions(-) create mode 100644 src/GameLogic/Discord/BridgedChatSender.cs create mode 100644 src/GameLogic/Discord/DiscordIntegrationConfiguration.cs create mode 100644 src/GameLogic/Discord/DiscordIntegrationFeaturePlugIn.cs create mode 100644 src/GameLogic/Discord/DiscordIntegrationInfo.cs create mode 100644 src/GameLogic/Discord/ExternalChatScope.cs create mode 100644 src/GameLogic/PlayerActions/Discord/DiscordIntegrationAction.cs create mode 100644 src/GameLogic/Views/IDiscordIntegrationViewPlugIn.cs create mode 100644 src/GameServer/MessageHandler/DiscordIntegrationInfoRequestHandlerPlugIn.cs create mode 100644 src/GameServer/MessageHandler/DiscordLinkCodeRequestHandlerPlugIn.cs create mode 100644 src/GameServer/MessageHandler/DiscordUnlinkRequestHandlerPlugIn.cs create mode 100644 src/GameServer/RemoteView/DiscordIntegrationViewPlugIn.cs create mode 100644 tests/MUnique.OpenMU.Tests/Discord/DiscordIntegrationActionTest.cs create mode 100644 tests/MUnique.OpenMU.Tests/Discord/DiscordIntegrationViewPlugInTest.cs diff --git a/docs-website/docs/admin-panel/plugins.md b/docs-website/docs/admin-panel/plugins.md index a96d82097f..71119d4974 100644 --- a/docs-website/docs/admin-panel/plugins.md +++ b/docs-website/docs/admin-panel/plugins.md @@ -88,6 +88,15 @@ 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). +## Discord integration of the client + +The feature plugin *Discord integration of the client* tells game clients which +ask for it how the server is connected to Discord: the invite link, the Discord +application of the Rich Presence, the link of the account and which chats are +mirrored. It also lets players link their account from the client, and sends +them the messages written in Discord as such. See +[Discord](../server-features/discord.md#the-game-client). + ## Writing your own plugin Implementing a plugin is documented next to the code, in the diff --git a/docs-website/docs/server-features/discord.md b/docs-website/docs/server-features/discord.md index fc5479c71a..48151e833b 100644 --- a/docs-website/docs/server-features/discord.md +++ b/docs-website/docs/server-features/discord.md @@ -267,6 +267,33 @@ Without the intent, players use `/say `. Discord only approves the intent for verified bots, which are in more than 100 Discord servers, with a reason. +## The game client + +[MuMain](https://github.com/sven-n/MuMain), the open source game client, has +its own Discord features. It asks the server how it's connected to Discord, when +the character enters the game: + +* its **Discord** button opens the invite to your Discord server, +* its **Rich Presence** shows the state of the player in the Discord profile, + with your Discord application, +* players link and unlink their account in a dialog, without typing + `/discord link`, +* messages written in Discord are shown as such, with the colours of the guild, + alliance or world chat, and can't be mistaken for messages of a character. + +For this, activate the feature plugin *Discord integration of the client* and +fill its configuration: + +| Setting | Description | +|---|---| +| `InviteUrl` | The invite link to your Discord server, e.g. `https://discord.gg/abc123`. Create one which doesn't expire. | +| `RichPresenceApplicationId` | The id of the Discord application which the client uses for the Rich Presence, usually the one of your bot (*General Information → Application ID* in the developer portal). | +| `RichPresenceLargeImageKey`, `RichPresenceSmallImageKey` | The keys of the images of the Rich Presence, uploaded under *Rich Presence → Art Assets*. | + +Every player can read these values, so they must not contain secrets. Older game +clients don't ask for them; they keep getting the messages from Discord as +normal chat messages with the `@` prefix. + ## Settings All settings are part of the `Discord` section of the configuration, so they can diff --git a/src/Discord/ChatBridge/DiscordChatText.cs b/src/Discord/ChatBridge/DiscordChatText.cs index 661d4f78ee..9adcf87a9d 100644 --- a/src/Discord/ChatBridge/DiscordChatText.cs +++ b/src/Discord/ChatBridge/DiscordChatText.cs @@ -6,6 +6,7 @@ namespace MUnique.OpenMU.Discord.ChatBridge; using System.Text; using System.Text.RegularExpressions; +using MUnique.OpenMU.GameLogic.Discord; /// /// Converts the texts of chat messages between the game and Discord. @@ -16,7 +17,7 @@ public static partial class DiscordChatText /// The prefix of the names of Discord users in the game. Character names can't contain it, /// so Discord users can't pretend to be a character in the game. /// - public const char DiscordSenderPrefix = '@'; + public const char DiscordSenderPrefix = BridgedChatSender.Prefix; /// /// The maximum length of a sender name in the game. diff --git a/src/GameLogic/Discord/BridgedChatSender.cs b/src/GameLogic/Discord/BridgedChatSender.cs new file mode 100644 index 0000000000..8ff0088c25 --- /dev/null +++ b/src/GameLogic/Discord/BridgedChatSender.cs @@ -0,0 +1,32 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Discord; + +/// +/// The sender of a chat message which was written outside of the game, e.g. in Discord, and bridged into it. +/// Its name is the name of a character with a prefix, which character names can't contain, +/// so it can't be mistaken for a character. +/// +public static class BridgedChatSender +{ + /// + /// The prefix of the names of bridged senders. + /// + public const char Prefix = '@'; + + /// + /// Determines whether the sender of a chat message is a bridged sender. + /// + /// The sender. + /// true, if the sender is bridged. + public static bool IsBridged(string sender) => sender.Length > 1 && sender[0] == Prefix; + + /// + /// Gets the name of a bridged sender without its prefix. + /// + /// The bridged sender. + /// The name without its prefix. + public static string GetName(string sender) => IsBridged(sender) ? sender[1..] : sender; +} diff --git a/src/GameLogic/Discord/DiscordIntegrationConfiguration.cs b/src/GameLogic/Discord/DiscordIntegrationConfiguration.cs new file mode 100644 index 0000000000..7c9cdf6c4f --- /dev/null +++ b/src/GameLogic/Discord/DiscordIntegrationConfiguration.cs @@ -0,0 +1,34 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Discord; + +/// +/// The configuration of the Discord integration, which the game client gets with the Discord integration info. +/// It holds no secrets: every player can read these values. +/// +public class DiscordIntegrationConfiguration +{ + /// + /// Gets or sets the invite link to the Discord server of the game server, e.g. https://discord.gg/abc123. + /// The client opens it with its Discord button. Empty for none. + /// + public string InviteUrl { get; set; } = string.Empty; + + /// + /// Gets or sets the id of the Discord application which the client uses for the Rich Presence. + /// Usually it's the application of the bot. Empty for none, then the client uses its own configuration. + /// + public string RichPresenceApplicationId { get; set; } = string.Empty; + + /// + /// Gets or sets the key of the large image of the Rich Presence, as uploaded to the Discord application. + /// + public string RichPresenceLargeImageKey { get; set; } = string.Empty; + + /// + /// Gets or sets the key of the small image of the Rich Presence, as uploaded to the Discord application. + /// + public string RichPresenceSmallImageKey { get; set; } = string.Empty; +} diff --git a/src/GameLogic/Discord/DiscordIntegrationFeaturePlugIn.cs b/src/GameLogic/Discord/DiscordIntegrationFeaturePlugIn.cs new file mode 100644 index 0000000000..151ef97cea --- /dev/null +++ b/src/GameLogic/Discord/DiscordIntegrationFeaturePlugIn.cs @@ -0,0 +1,40 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Discord; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.PlugIns; + +/// +/// The Discord integration of the game client: it tells clients which ask for it how the server is connected +/// to Discord, lets the players link their account from the client, and sends them the messages which were +/// written in Discord as such. When it's deactivated, the clients get no Discord integration info. +/// +[PlugIn] +[Display(Name = nameof(PlugInResources.DiscordIntegrationFeaturePlugIn_Name), Description = nameof(PlugInResources.DiscordIntegrationFeaturePlugIn_Description), ResourceType = typeof(PlugInResources))] +[Guid("26096524-5D7E-4D13-925C-04AAC0340B70")] +public class DiscordIntegrationFeaturePlugIn : IFeaturePlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration +{ + /// + public DiscordIntegrationConfiguration? Configuration { get; set; } + + /// + /// Gets the configuration of the Discord integration of the game context. + /// + /// The game context. + /// The configuration; null, if the Discord integration is deactivated. + public static DiscordIntegrationConfiguration? GetConfiguration(IGameContext gameContext) + { + return gameContext.FeaturePlugIns.GetPlugIn() is { } plugIn + ? plugIn.Configuration ?? new DiscordIntegrationConfiguration() + : null; + } + + /// + public object CreateDefaultConfig() + { + return new DiscordIntegrationConfiguration(); + } +} diff --git a/src/GameLogic/Discord/DiscordIntegrationInfo.cs b/src/GameLogic/Discord/DiscordIntegrationInfo.cs new file mode 100644 index 0000000000..56a1509d1d --- /dev/null +++ b/src/GameLogic/Discord/DiscordIntegrationInfo.cs @@ -0,0 +1,26 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Discord; + +/// +/// How the game server is connected to Discord, from the point of view of one player. +/// +/// The name of the Discord user which the account is linked to; null, if it isn't linked. +/// If set to true, the chat of the guild of the character is mirrored to Discord. +/// If set to true, the chat of the alliance of the character is mirrored to Discord. +/// If set to true, the world chat is mirrored to Discord. +/// The configured values; null, if the Discord integration is deactivated. +public record DiscordIntegrationInfo( + string? LinkedUserName, + bool IsGuildChatBridged, + bool IsAllianceChatBridged, + bool IsWorldChatBridged, + DiscordIntegrationConfiguration? Configuration) +{ + /// + /// Gets the info of a server without Discord integration. + /// + public static DiscordIntegrationInfo None { get; } = new(null, false, false, false, null); +} diff --git a/src/GameLogic/Discord/ExternalChatScope.cs b/src/GameLogic/Discord/ExternalChatScope.cs new file mode 100644 index 0000000000..6f8e37a03f --- /dev/null +++ b/src/GameLogic/Discord/ExternalChatScope.cs @@ -0,0 +1,26 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Discord; + +/// +/// The chat of the game which a message written outside of the game belongs to. +/// +public enum ExternalChatScope +{ + /// + /// The chat of a guild. + /// + Guild, + + /// + /// The chat of an alliance. + /// + Alliance, + + /// + /// The world chat. + /// + World, +} diff --git a/src/GameLogic/PlayerActions/Discord/DiscordIntegrationAction.cs b/src/GameLogic/PlayerActions/Discord/DiscordIntegrationAction.cs new file mode 100644 index 0000000000..2c6902ae35 --- /dev/null +++ b/src/GameLogic/PlayerActions/Discord/DiscordIntegrationAction.cs @@ -0,0 +1,152 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlayerActions.Discord; + +using MUnique.OpenMU.GameLogic.AccountLinking; +using MUnique.OpenMU.GameLogic.Discord; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.GameLogic.PlugIns.GameEvents; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Persistence; + +/// +/// The actions of the Discord integration of the game client: the integration info, and linking and unlinking +/// the account. +/// +public class DiscordIntegrationAction +{ + /// + /// Shows the player how the game server is connected to Discord. + /// + /// The player. + public async ValueTask ShowIntegrationInfoAsync(Player player) + { + var info = await this.GetIntegrationInfoAsync(player).ConfigureAwait(false); + await player.InvokeViewPlugInAsync(p => p.ShowDiscordIntegrationInfoAsync(info)).ConfigureAwait(false); + } + + /// + /// Creates a one-time code to link the account of the player to a Discord user, and shows it. + /// + /// The player. + public async ValueTask RequestLinkCodeAsync(Player player) + { + string? code = null; + if (DiscordIntegrationFeaturePlugIn.GetConfiguration(player.GameContext) is not null + && player.Account is { } account) + { + code = await CreateLinkService(player).CreateCodeAsync(account.GetId(), AccountLinkService.DiscordProvider, player.SelectedCharacter?.Name).ConfigureAwait(false); + } + + await player.InvokeViewPlugInAsync(p => p.ShowDiscordLinkCodeAsync(code, AccountLinkService.CodeValidity)).ConfigureAwait(false); + } + + /// + /// Removes the link of the account of the player to a Discord user, and shows the updated integration info. + /// + /// The player. + public async ValueTask UnlinkAndShowInfoAsync(Player player) + { + await UnlinkAsync(player).ConfigureAwait(false); + await this.ShowIntegrationInfoAsync(player).ConfigureAwait(false); + } + + /// + /// Removes the link of the account of the player to a Discord user. + /// + /// The player. + /// true, if a link was removed. + public static async ValueTask UnlinkAsync(Player player) + { + if (player.Account is not { } account + || await CreateLinkService(player).UnlinkAccountAsync(account.GetId(), AccountLinkService.DiscordProvider).ConfigureAwait(false) is not { } userId) + { + return false; + } + + if (player.GameContext is IGameServerContext gameServerContext) + { + // The Discord bot removes the roles of the user. + await gameServerContext.EventPublisher.GameEventAsync( + new AccountUnlinkedEvent(gameServerContext.Id, DateTime.UtcNow, AccountLinkService.DiscordProvider, userId)).ConfigureAwait(false); + } + + return true; + } + + /// + /// Gets how the game server is connected to Discord, from the point of view of the player. + /// + /// The player. + /// The integration info. + public async ValueTask GetIntegrationInfoAsync(Player player) + { + if (DiscordIntegrationFeaturePlugIn.GetConfiguration(player.GameContext) is not { } configuration) + { + return DiscordIntegrationInfo.None; + } + + var linkedUserName = await GetLinkedUserNameAsync(player).ConfigureAwait(false); + var (isGuildChatBridged, isAllianceChatBridged) = await GetGuildChatBindingsAsync(player).ConfigureAwait(false); + return new DiscordIntegrationInfo(linkedUserName, isGuildChatBridged, isAllianceChatBridged, IsWorldChatPublished(player), configuration); + } + + private static AccountLinkService CreateLinkService(Player player) + { + return new AccountLinkService(() => player.GameContext.PersistenceContextProvider.CreateNewPlayerContext(player.GameContext.Configuration)); + } + + private static async ValueTask GetLinkedUserNameAsync(Player player) + { + if (player.Account is not { } account) + { + return null; + } + + var link = await CreateLinkService(player).GetLinkAsync(account.GetId(), AccountLinkService.DiscordProvider).ConfigureAwait(false); + return link?.ExternalUserId is null ? null : link.ExternalUserName ?? string.Empty; + } + + private static async ValueTask<(bool Guild, bool Alliance)> GetGuildChatBindingsAsync(Player player) + { + if (player.SelectedCharacter is not { } character) + { + return (false, false); + } + + try + { + using var guildContext = player.GameContext.PersistenceContextProvider.CreateNewGuildContext(); + if (await guildContext.GetGuildMembershipAsync(character.Name).ConfigureAwait(false) is not { } membership) + { + return (false, false); + } + + using var context = player.GameContext.PersistenceContextProvider.CreateNewPlayerContext(player.GameContext.Configuration); + var bindings = await context.GetAsync().ConfigureAwait(false); + var allianceId = membership.AllianceMasterGuildId ?? membership.GuildId; + var isGuildChatBridged = bindings.Any(b => b is { Scope: GuildChatScope.Guild } && b.GuildId == membership.GuildId); + var isAllianceChatBridged = bindings.Any(b => b is { Scope: GuildChatScope.Alliance } && b.GuildId == allianceId); + return (isGuildChatBridged, isAllianceChatBridged); + } + catch (Exception ex) + { + player.Logger.LogWarning(ex, "The bindings of the guild chat couldn't be checked."); + return (false, false); + } + } + + /// + /// The world chat leaves the game only when the game events publisher publishes the chat; + /// the bot then mirrors it to the world chat channel of its layout. + /// + private static bool IsWorldChatPublished(Player player) + { + return player.GameContext.PlugInManager.GetActivePlugInsOf() + .OfType() + .Any(p => p.Configuration?.PublishChatMessages is true); + } +} diff --git a/src/GameLogic/PlugIns/ChatCommands/DiscordChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/DiscordChatCommandPlugIn.cs index 0623d46302..d6b796d157 100644 --- a/src/GameLogic/PlugIns/ChatCommands/DiscordChatCommandPlugIn.cs +++ b/src/GameLogic/PlugIns/ChatCommands/DiscordChatCommandPlugIn.cs @@ -6,8 +6,8 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; using System.Runtime.InteropServices; using MUnique.OpenMU.GameLogic.AccountLinking; +using MUnique.OpenMU.GameLogic.PlayerActions.Discord; using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands.Arguments; -using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.Persistence; using MUnique.OpenMU.PlugIns; @@ -47,14 +47,7 @@ protected override async ValueTask DoHandleCommandAsync(Player player, DiscordCh await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.DiscordLinkCode), code, (int)AccountLinkService.CodeValidity.TotalMinutes).ConfigureAwait(false); break; case DiscordChatCommandArgs.UnlinkAction: - if (await service.UnlinkAccountAsync(accountId, AccountLinkService.DiscordProvider).ConfigureAwait(false) is { } userId - && player.GameContext is IGameServerContext gameServerContext) - { - // The Discord bot removes the roles of the user. - await gameServerContext.EventPublisher.GameEventAsync( - new AccountUnlinkedEvent(gameServerContext.Id, DateTime.UtcNow, AccountLinkService.DiscordProvider, userId)).ConfigureAwait(false); - } - + await DiscordIntegrationAction.UnlinkAsync(player).ConfigureAwait(false); await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.DiscordUnlinked)).ConfigureAwait(false); break; default: diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index a995dc3561..1ba612dffc 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -4506,5 +4506,23 @@ public static string CastleSiegeLifeStoneConsumeHandlerPlugIn_Name { /// Gets the localized LostConnectionDetectionConfiguration_Timeout_Description text. public static string LostConnectionDetectionConfiguration_Timeout_Description => ResourceManager.GetString("LostConnectionDetectionConfiguration_Timeout_Description", resourceCulture)!; + + /// + /// Looks up a localized string similar to Discord integration of the client. + /// + public static string DiscordIntegrationFeaturePlugIn_Name { + get { + return ResourceManager.GetString("DiscordIntegrationFeaturePlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Tells game clients which ask for it how the server is connected to Discord (invite link, Rich Presence application, account link, mirrored chats), lets players link their account from the client, and sends them messages written in Discord as such.. + /// + public static string DiscordIntegrationFeaturePlugIn_Description { + get { + return ResourceManager.GetString("DiscordIntegrationFeaturePlugIn_Description", resourceCulture); + } + } } } diff --git a/src/GameLogic/Properties/PlugInResources.de.resx b/src/GameLogic/Properties/PlugInResources.de.resx index a7c91ca652..341d313368 100644 --- a/src/GameLogic/Properties/PlugInResources.de.resx +++ b/src/GameLogic/Properties/PlugInResources.de.resx @@ -1788,4 +1788,10 @@ Teilt Spielern beim Betreten des Spiels mit, ob der Chat ihrer Gilde oder Allianz nach Discord gespiegelt wird. + + Discord-Integration des Clients + + + Teilt Game-Clients, die danach fragen, mit, wie der Server mit Discord verbunden ist (Einladungslink, Rich-Presence-Anwendung, Kontoverknüpfung, gespiegelte Chats), lässt Spieler ihr Konto im Client verknüpfen und schickt ihnen in Discord geschriebene Nachrichten als solche. + diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 24b82c7b98..6115a7a033 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1631,4 +1631,10 @@ Tells players when they enter the game, if the chat of their guild or alliance is mirrored to Discord. + + Discord integration of the client + + + Tells game clients which ask for it how the server is connected to Discord (invite link, Rich Presence application, account link, mirrored chats), lets players link their account from the client, and sends them messages written in Discord as such. + diff --git a/src/GameLogic/Views/IDiscordIntegrationViewPlugIn.cs b/src/GameLogic/Views/IDiscordIntegrationViewPlugIn.cs new file mode 100644 index 0000000000..b6fa31e589 --- /dev/null +++ b/src/GameLogic/Views/IDiscordIntegrationViewPlugIn.cs @@ -0,0 +1,36 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Views; + +using MUnique.OpenMU.GameLogic.Discord; + +/// +/// The view of the Discord integration of the game client. +/// +public interface IDiscordIntegrationViewPlugIn : IViewPlugIn +{ + /// + /// Shows how the game server is connected to Discord. The client asked for it, + /// so it supports the Discord integration from then on. + /// + /// The info. + ValueTask ShowDiscordIntegrationInfoAsync(DiscordIntegrationInfo info); + + /// + /// Shows the one-time code to link the account to a Discord user. + /// + /// The code; null, if linking isn't available. + /// The time the code is valid. + ValueTask ShowDiscordLinkCodeAsync(string? code, TimeSpan validity); + + /// + /// Shows a chat message which was written outside of the game, if the client supports it. + /// + /// The chat which the message belongs to. + /// The name of the sender, without prefix. + /// The message, without the prefix of its chat. + /// true, if it was shown; false, if the client didn't announce the support for it. + ValueTask TryShowExternalChatMessageAsync(ExternalChatScope scope, string senderName, string message); +} diff --git a/src/GameServer/GameServer.cs b/src/GameServer/GameServer.cs index 4f0e26e2f7..152e46f885 100644 --- a/src/GameServer/GameServer.cs +++ b/src/GameServer/GameServer.cs @@ -12,6 +12,7 @@ namespace MUnique.OpenMU.GameServer; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Discord; using MUnique.OpenMU.GameLogic.Properties; using MUnique.OpenMU.GameLogic.Views; using MUnique.OpenMU.GameLogic.Views.Guild; @@ -248,7 +249,7 @@ public async ValueTask AllianceChatMessageAsync(uint guildId, string sender, str /// public async ValueTask WorldChatMessageAsync(string sender, string message) { - await this._gameContext.ForEachPlayerAsync(player => player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.WorldChatMessage), sender, message).AsTask()).ConfigureAwait(false); + await this._gameContext.ForEachPlayerAsync(player => ShowWorldChatMessageAsync(player, sender, message).AsTask()).ConfigureAwait(false); } /// @@ -500,6 +501,22 @@ public async ValueTask DisposeAsync() } } + /// + /// Shows a message of the world chat. A message which was written in Discord is sent as external chat message + /// to the clients which support it. + /// + private static async ValueTask ShowWorldChatMessageAsync(Player player, string sender, string message) + { + if (BridgedChatSender.IsBridged(sender) + && player.ViewPlugIns.GetPlugIn() is { } discordView + && await discordView.TryShowExternalChatMessageAsync(ExternalChatScope.World, BridgedChatSender.GetName(sender), message).ConfigureAwait(false)) + { + return; + } + + await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.WorldChatMessage), sender, message).ConfigureAwait(false); + } + /// /// Moves the players of other game servers off the maps of this game server, which they entered /// because their own game server doesn't host these maps (see ). diff --git a/src/GameServer/MessageHandler/DiscordIntegrationInfoRequestHandlerPlugIn.cs b/src/GameServer/MessageHandler/DiscordIntegrationInfoRequestHandlerPlugIn.cs new file mode 100644 index 0000000000..edc488a20a --- /dev/null +++ b/src/GameServer/MessageHandler/DiscordIntegrationInfoRequestHandlerPlugIn.cs @@ -0,0 +1,42 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameServer.MessageHandler; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.PlayerActions.Discord; +using MUnique.OpenMU.Network.Packets.ClientToServer; +using MUnique.OpenMU.Network.PlugIns; +using MUnique.OpenMU.PlugIns; + +/// +/// Handler for the request of the Discord integration info. The request also announces that the client supports the external chat messages. +/// +[PlugIn] +[Display(Name = nameof(PlugInResources.DiscordIntegrationInfoRequestHandlerPlugIn_Name), Description = nameof(PlugInResources.DiscordIntegrationInfoRequestHandlerPlugIn_Description), ResourceType = typeof(PlugInResources))] +[Guid("956C19CC-95CD-4EC0-8E38-63D62B47B127")] +[MinimumClient(106, 3, ClientLanguage.Invariant)] +[BelongsToGroup(ChatCommandGroupHandlerPlugIn.GroupKey)] +internal class DiscordIntegrationInfoRequestHandlerPlugIn : ISubPacketHandlerPlugIn +{ + private readonly DiscordIntegrationAction _action = new(); + + /// + public bool IsEncryptionExpected => false; + + /// + public byte Key => DiscordIntegrationInfoRequest.SubCode; + + /// + public async ValueTask HandlePacketAsync(Player player, Memory packet) + { + if (player.SelectedCharacter is null) + { + return; + } + + await this._action.ShowIntegrationInfoAsync(player).ConfigureAwait(false); + } +} diff --git a/src/GameServer/MessageHandler/DiscordLinkCodeRequestHandlerPlugIn.cs b/src/GameServer/MessageHandler/DiscordLinkCodeRequestHandlerPlugIn.cs new file mode 100644 index 0000000000..970b76419f --- /dev/null +++ b/src/GameServer/MessageHandler/DiscordLinkCodeRequestHandlerPlugIn.cs @@ -0,0 +1,42 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameServer.MessageHandler; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.PlayerActions.Discord; +using MUnique.OpenMU.Network.Packets.ClientToServer; +using MUnique.OpenMU.Network.PlugIns; +using MUnique.OpenMU.PlugIns; + +/// +/// Handler for the request of a code to link the account to a Discord user. +/// +[PlugIn] +[Display(Name = nameof(PlugInResources.DiscordLinkCodeRequestHandlerPlugIn_Name), Description = nameof(PlugInResources.DiscordLinkCodeRequestHandlerPlugIn_Description), ResourceType = typeof(PlugInResources))] +[Guid("5D383E4B-82FC-448A-8AAB-B38682150657")] +[MinimumClient(106, 3, ClientLanguage.Invariant)] +[BelongsToGroup(ChatCommandGroupHandlerPlugIn.GroupKey)] +internal class DiscordLinkCodeRequestHandlerPlugIn : ISubPacketHandlerPlugIn +{ + private readonly DiscordIntegrationAction _action = new(); + + /// + public bool IsEncryptionExpected => false; + + /// + public byte Key => DiscordLinkCodeRequest.SubCode; + + /// + public async ValueTask HandlePacketAsync(Player player, Memory packet) + { + if (player.SelectedCharacter is null) + { + return; + } + + await this._action.RequestLinkCodeAsync(player).ConfigureAwait(false); + } +} diff --git a/src/GameServer/MessageHandler/DiscordUnlinkRequestHandlerPlugIn.cs b/src/GameServer/MessageHandler/DiscordUnlinkRequestHandlerPlugIn.cs new file mode 100644 index 0000000000..ae0cc3f07c --- /dev/null +++ b/src/GameServer/MessageHandler/DiscordUnlinkRequestHandlerPlugIn.cs @@ -0,0 +1,42 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameServer.MessageHandler; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.PlayerActions.Discord; +using MUnique.OpenMU.Network.Packets.ClientToServer; +using MUnique.OpenMU.Network.PlugIns; +using MUnique.OpenMU.PlugIns; + +/// +/// Handler for the request to remove the link of the account to a Discord user. +/// +[PlugIn] +[Display(Name = nameof(PlugInResources.DiscordUnlinkRequestHandlerPlugIn_Name), Description = nameof(PlugInResources.DiscordUnlinkRequestHandlerPlugIn_Description), ResourceType = typeof(PlugInResources))] +[Guid("AE310CAA-1876-453A-B3B4-58137915CF50")] +[MinimumClient(106, 3, ClientLanguage.Invariant)] +[BelongsToGroup(ChatCommandGroupHandlerPlugIn.GroupKey)] +internal class DiscordUnlinkRequestHandlerPlugIn : ISubPacketHandlerPlugIn +{ + private readonly DiscordIntegrationAction _action = new(); + + /// + public bool IsEncryptionExpected => false; + + /// + public byte Key => DiscordUnlinkRequest.SubCode; + + /// + public async ValueTask HandlePacketAsync(Player player, Memory packet) + { + if (player.SelectedCharacter is null) + { + return; + } + + await this._action.UnlinkAndShowInfoAsync(player).ConfigureAwait(false); + } +} diff --git a/src/GameServer/Properties/PlugInResources.Designer.cs b/src/GameServer/Properties/PlugInResources.Designer.cs index fc9f9753bd..daeca1be0f 100644 --- a/src/GameServer/Properties/PlugInResources.Designer.cs +++ b/src/GameServer/Properties/PlugInResources.Designer.cs @@ -7540,5 +7540,77 @@ public static string CastleSiegeLifeStoneStatePlugIn_Name { /// Gets the localized PingHandlerPlugIn_Description text. public static string PingHandlerPlugIn_Description => ResourceManager.GetString("PingHandlerPlugIn_Description", resourceCulture)!; + + /// + /// Looks up a localized string similar to Discord integration info request handler. + /// + public static string DiscordIntegrationInfoRequestHandlerPlugIn_Name { + get { + return ResourceManager.GetString("DiscordIntegrationInfoRequestHandlerPlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Handles the request of the Discord integration info (F5 02).. + /// + public static string DiscordIntegrationInfoRequestHandlerPlugIn_Description { + get { + return ResourceManager.GetString("DiscordIntegrationInfoRequestHandlerPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Discord link code request handler. + /// + public static string DiscordLinkCodeRequestHandlerPlugIn_Name { + get { + return ResourceManager.GetString("DiscordLinkCodeRequestHandlerPlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Handles the request of a code to link the account to a Discord user (F5 04).. + /// + public static string DiscordLinkCodeRequestHandlerPlugIn_Description { + get { + return ResourceManager.GetString("DiscordLinkCodeRequestHandlerPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Discord unlink request handler. + /// + public static string DiscordUnlinkRequestHandlerPlugIn_Name { + get { + return ResourceManager.GetString("DiscordUnlinkRequestHandlerPlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Handles the request to remove the link of the account to a Discord user (F5 06).. + /// + public static string DiscordUnlinkRequestHandlerPlugIn_Description { + get { + return ResourceManager.GetString("DiscordUnlinkRequestHandlerPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Discord integration view. + /// + public static string DiscordIntegrationViewPlugIn_Name { + get { + return ResourceManager.GetString("DiscordIntegrationViewPlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sends the Discord integration info, the link code and the messages written in Discord to the game client.. + /// + public static string DiscordIntegrationViewPlugIn_Description { + get { + return ResourceManager.GetString("DiscordIntegrationViewPlugIn_Description", resourceCulture); + } + } } } diff --git a/src/GameServer/Properties/PlugInResources.de.resx b/src/GameServer/Properties/PlugInResources.de.resx index 08bc18a0c1..3ee2cbe41c 100644 --- a/src/GameServer/Properties/PlugInResources.de.resx +++ b/src/GameServer/Properties/PlugInResources.de.resx @@ -2565,4 +2565,28 @@ Verarbeitet den Ping, den der Client periodisch sendet, und der meldet, dass er aktiv ist. + + Handler für die Anfrage der Discord-Integrationsinfo + + + Verarbeitet die Anfrage der Discord-Integrationsinfo (F5 02). + + + Handler für die Anfrage eines Discord-Verknüpfungscodes + + + Verarbeitet die Anfrage eines Codes, um das Konto mit einem Discord-Benutzer zu verknüpfen (F5 04). + + + Handler für das Aufheben der Discord-Verknüpfung + + + Verarbeitet die Anfrage, die Verknüpfung des Kontos mit einem Discord-Benutzer aufzuheben (F5 06). + + + Ansicht der Discord-Integration + + + Sendet die Discord-Integrationsinfo, den Verknüpfungscode und die in Discord geschriebenen Nachrichten an den Game-Client. + diff --git a/src/GameServer/Properties/PlugInResources.resx b/src/GameServer/Properties/PlugInResources.resx index e78ac27545..ef6ff05314 100644 --- a/src/GameServer/Properties/PlugInResources.resx +++ b/src/GameServer/Properties/PlugInResources.resx @@ -2615,4 +2615,28 @@ Packet handler for cash shop gift requests (0xD2, 0x04 identifier). + + Discord integration info request handler + + + Handles the request of the Discord integration info (F5 02). + + + Discord link code request handler + + + Handles the request of a code to link the account to a Discord user (F5 04). + + + Discord unlink request handler + + + Handles the request to remove the link of the account to a Discord user (F5 06). + + + Discord integration view + + + Sends the Discord integration info, the link code and the messages written in Discord to the game client. + diff --git a/src/GameServer/RemoteView/ChatViewPlugIn.cs b/src/GameServer/RemoteView/ChatViewPlugIn.cs index aab4d57080..3133b563a6 100644 --- a/src/GameServer/RemoteView/ChatViewPlugIn.cs +++ b/src/GameServer/RemoteView/ChatViewPlugIn.cs @@ -5,6 +5,7 @@ namespace MUnique.OpenMU.GameServer.RemoteView; using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.Discord; using MUnique.OpenMU.GameLogic.Views; using MUnique.OpenMU.Network.Packets.ServerToClient; using MUnique.OpenMU.PlugIns; @@ -22,6 +23,16 @@ public class ChatViewPlugIn : IChatViewPlugIn /// private const string GensMessagePrefix = "$"; + /// + /// The prefix of a chat message to the guild. + /// + private const string GuildMessagePrefix = "@"; + + /// + /// The prefix of a chat message to the alliance. + /// + private const string AllianceMessagePrefix = "@@"; + private readonly RemotePlayer _player; /// @@ -36,6 +47,11 @@ public ChatViewPlugIn(RemotePlayer player) /// public async ValueTask ChatMessageAsync(string message, string sender, ChatMessageType type) { + if (await this.TryShowAsExternalChatMessageAsync(message, sender, type).ConfigureAwait(false)) + { + return; + } + if (type == ChatMessageType.Gens && message.StartsWith(GensMessagePrefix, StringComparison.Ordinal)) { // The game client removes two characters of a message to the gens, instead of just the prefix. @@ -54,4 +70,26 @@ private static ChatMessage.ChatMessageType ConvertChatMessageType(ChatMessageTyp return Network.Packets.ServerToClient.ChatMessage.ChatMessageType.Normal; } -} \ No newline at end of file + + /// + /// Sends a guild or alliance message of a sender which was bridged from Discord as external chat message, + /// when the client supports it. Then the client doesn't need to recognize the sender by its prefix. + /// + private async ValueTask TryShowAsExternalChatMessageAsync(string message, string sender, ChatMessageType type) + { + if (!BridgedChatSender.IsBridged(sender) + || type is not (ChatMessageType.Guild or ChatMessageType.Alliance) + || this._player.ViewPlugIns.GetPlugIn() is not { } discordView) + { + return false; + } + + var (scope, chatPrefix) = type == ChatMessageType.Guild + ? (ExternalChatScope.Guild, GuildMessagePrefix) + : (ExternalChatScope.Alliance, AllianceMessagePrefix); + + // The prefix of the chat says the same as the scope. + var text = message.StartsWith(chatPrefix, StringComparison.Ordinal) ? message[chatPrefix.Length..] : message; + return await discordView.TryShowExternalChatMessageAsync(scope, BridgedChatSender.GetName(sender), text).ConfigureAwait(false); + } +} diff --git a/src/GameServer/RemoteView/DiscordIntegrationViewPlugIn.cs b/src/GameServer/RemoteView/DiscordIntegrationViewPlugIn.cs new file mode 100644 index 0000000000..a8425c1562 --- /dev/null +++ b/src/GameServer/RemoteView/DiscordIntegrationViewPlugIn.cs @@ -0,0 +1,86 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameServer.RemoteView; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.Discord; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.Network.Packets.ServerToClient; +using MUnique.OpenMU.Network.PlugIns; +using MUnique.OpenMU.PlugIns; +using DiscordIntegrationInfo = MUnique.OpenMU.GameLogic.Discord.DiscordIntegrationInfo; + +/// +/// The default implementation of the which sends the Discord +/// integration messages to the game client. +/// +[PlugIn] +[Display(Name = nameof(PlugInResources.DiscordIntegrationViewPlugIn_Name), Description = nameof(PlugInResources.DiscordIntegrationViewPlugIn_Description), ResourceType = typeof(PlugInResources))] +[Guid("D1CFC066-B31F-4FE9-8435-4AE4AEB92B5A")] +[MinimumClient(106, 3, ClientLanguage.Invariant)] +public class DiscordIntegrationViewPlugIn : IDiscordIntegrationViewPlugIn +{ + private readonly RemotePlayer _player; + + /// + /// Set when the client requested the integration info. Only then it understands the external chat messages; + /// other clients get them as normal chat messages. + /// + private bool _isSupportedByClient; + + /// + /// Initializes a new instance of the class. + /// + /// The player. + public DiscordIntegrationViewPlugIn(RemotePlayer player) => this._player = player; + + /// + public async ValueTask ShowDiscordIntegrationInfoAsync(DiscordIntegrationInfo info) + { + this._isSupportedByClient = true; + var configuration = info.Configuration; + await this._player.Connection.SendDiscordIntegrationInfoAsync( + info.LinkedUserName is not null, + info.IsGuildChatBridged, + info.IsAllianceChatBridged, + info.IsWorldChatBridged, + configuration?.RichPresenceApplicationId ?? string.Empty, + configuration?.RichPresenceLargeImageKey ?? string.Empty, + configuration?.RichPresenceSmallImageKey ?? string.Empty, + configuration?.InviteUrl ?? string.Empty, + info.LinkedUserName ?? string.Empty).ConfigureAwait(false); + } + + /// + public async ValueTask ShowDiscordLinkCodeAsync(string? code, TimeSpan validity) + { + var result = code is null ? DiscordLinkCode.DiscordLinkCodeResult.NotAvailable : DiscordLinkCode.DiscordLinkCodeResult.Success; + var validMinutes = (byte)Math.Clamp(validity.TotalMinutes, 0, byte.MaxValue); + await this._player.Connection.SendDiscordLinkCodeAsync(result, code ?? string.Empty, validMinutes).ConfigureAwait(false); + } + + /// + public async ValueTask TryShowExternalChatMessageAsync(ExternalChatScope scope, string senderName, string message) + { + if (!this._isSupportedByClient) + { + return false; + } + + await this._player.Connection.SendExternalChatMessageAsync( + ExternalChatMessage.ExternalChatSource.Discord, + Convert(scope), + senderName, + message).ConfigureAwait(false); + return true; + } + + private static ExternalChatMessage.ExternalChatScope Convert(ExternalChatScope scope) => scope switch + { + ExternalChatScope.Guild => ExternalChatMessage.ExternalChatScope.Guild, + ExternalChatScope.Alliance => ExternalChatMessage.ExternalChatScope.Alliance, + _ => ExternalChatMessage.ExternalChatScope.World, + }; +} diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordIntegrationActionTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordIntegrationActionTest.cs new file mode 100644 index 0000000000..a47eff0119 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordIntegrationActionTest.cs @@ -0,0 +1,82 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Discord; + +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.AccountLinking; +using MUnique.OpenMU.GameLogic.Discord; +using MUnique.OpenMU.GameLogic.PlayerActions.Discord; +using MUnique.OpenMU.Persistence; + +/// +/// Tests for the . +/// +[TestFixture] +public class DiscordIntegrationActionTest +{ + /// + /// Tests that a server without active Discord integration tells the client nothing. + /// + [Test] + public async Task InactiveIntegrationGivesNoInfoAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + + var info = await new DiscordIntegrationAction().GetIntegrationInfoAsync(player).ConfigureAwait(false); + + Assert.That(info, Is.EqualTo(DiscordIntegrationInfo.None)); + } + + /// + /// Tests that the info carries the configuration, and the Discord user once the account is linked. + /// + [Test] + public async Task InfoCarriesConfigurationAndLinkAsync() + { + var player = await CreatePlayerWithIntegrationAsync().ConfigureAwait(false); + var service = CreateLinkService(player); + + var unlinked = await new DiscordIntegrationAction().GetIntegrationInfoAsync(player).ConfigureAwait(false); + var code = await service.CreateCodeAsync(player.Account!.GetId(), AccountLinkService.DiscordProvider, "Hero").ConfigureAwait(false); + await service.LinkAsync(AccountLinkService.DiscordProvider, code, "42", "sven").ConfigureAwait(false); + var linked = await new DiscordIntegrationAction().GetIntegrationInfoAsync(player).ConfigureAwait(false); + + Assert.That(unlinked.Configuration?.InviteUrl, Is.EqualTo("https://discord.gg/abc123")); + Assert.That(unlinked.LinkedUserName, Is.Null); + Assert.That(linked.LinkedUserName, Is.EqualTo("sven")); + } + + /// + /// Tests that unlinking removes the link which the info shows. + /// + [Test] + public async Task UnlinkRemovesTheLinkAsync() + { + var player = await CreatePlayerWithIntegrationAsync().ConfigureAwait(false); + var service = CreateLinkService(player); + var code = await service.CreateCodeAsync(player.Account!.GetId(), AccountLinkService.DiscordProvider, "Hero").ConfigureAwait(false); + await service.LinkAsync(AccountLinkService.DiscordProvider, code, "42", "sven").ConfigureAwait(false); + + var removed = await DiscordIntegrationAction.UnlinkAsync(player).ConfigureAwait(false); + var info = await new DiscordIntegrationAction().GetIntegrationInfoAsync(player).ConfigureAwait(false); + + Assert.That(removed, Is.True); + Assert.That(info.LinkedUserName, Is.Null); + } + + private static async ValueTask CreatePlayerWithIntegrationAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + player.SelectedCharacter!.Name = "Hero"; + var configuration = new DiscordIntegrationConfiguration { InviteUrl = "https://discord.gg/abc123" }; + player.GameContext.FeaturePlugIns.AddPlugIn(new DiscordIntegrationFeaturePlugIn { Configuration = configuration }, true); + return player; + } + + private static AccountLinkService CreateLinkService(Player player) + { + return new AccountLinkService(() => player.GameContext.PersistenceContextProvider.CreateNewPlayerContext(player.GameContext.Configuration)); + } +} diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordIntegrationViewPlugInTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordIntegrationViewPlugInTest.cs new file mode 100644 index 0000000000..c235178386 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordIntegrationViewPlugInTest.cs @@ -0,0 +1,203 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Discord; + +using System.IO; +using System.IO.Pipelines; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Discord; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.GameServer; +using MUnique.OpenMU.GameServer.RemoteView; +using MUnique.OpenMU.Network; +using MUnique.OpenMU.Network.Packets.ServerToClient; +using MUnique.OpenMU.Network.PlugIns; +using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.PlugIns; +using Nito.AsyncEx; +using DiscordIntegrationInfo = MUnique.OpenMU.GameLogic.Discord.DiscordIntegrationInfo; +using DiscordIntegrationInfoPacket = MUnique.OpenMU.Network.Packets.ServerToClient.DiscordIntegrationInfoRef; + +/// +/// Tests for the and the way the chat reaches the clients which +/// support the Discord integration. +/// +[TestFixture] +public class DiscordIntegrationViewPlugInTest +{ + private static readonly ClientVersion MuMainVersion = new(106, 3, ClientLanguage.Invariant); + + /// + /// Tests that the integration info carries the configured values and the link state. + /// + [Test] + public async Task IntegrationInfoCarriesTheValuesAsync() + { + var (player, output) = CreatePlayer(); + var configuration = new DiscordIntegrationConfiguration + { + InviteUrl = "https://discord.gg/abc123", + RichPresenceApplicationId = "123456789012345678", + RichPresenceLargeImageKey = "logo", + }; + var plugIn = new DiscordIntegrationViewPlugIn(player); + + await plugIn.ShowDiscordIntegrationInfoAsync(new DiscordIntegrationInfo("sven", true, false, true, configuration)).ConfigureAwait(false); + + var packet = new DiscordIntegrationInfoPacket(ReadPackets(output)[0]); + Assert.That(packet.Header.Code, Is.EqualTo(0xF5)); + Assert.That(packet.Header.SubCode, Is.EqualTo(0x03)); + Assert.That(packet.IsAccountLinked, Is.True); + Assert.That(packet.IsGuildChatBridged, Is.True); + Assert.That(packet.IsAllianceChatBridged, Is.False); + Assert.That(packet.IsWorldChatBridged, Is.True); + Assert.That(packet.InviteUrl, Is.EqualTo("https://discord.gg/abc123")); + Assert.That(packet.RichPresenceApplicationId, Is.EqualTo("123456789012345678")); + Assert.That(packet.RichPresenceLargeImageKey, Is.EqualTo("logo")); + Assert.That(packet.RichPresenceSmallImageKey, Is.Empty); + Assert.That(packet.LinkedUserName, Is.EqualTo("sven")); + } + + /// + /// Tests that a missing link code is reported as not available. + /// + [Test] + public async Task MissingLinkCodeIsNotAvailableAsync() + { + var (player, output) = CreatePlayer(); + var plugIn = new DiscordIntegrationViewPlugIn(player); + + await plugIn.ShowDiscordLinkCodeAsync(null, TimeSpan.FromMinutes(10)).ConfigureAwait(false); + await plugIn.ShowDiscordLinkCodeAsync("ABCD-EFGH", TimeSpan.FromMinutes(10)).ConfigureAwait(false); + + var packets = ReadPackets(output); + var missing = new DiscordLinkCodeRef(packets[0]); + var created = new DiscordLinkCodeRef(packets[1]); + Assert.That(missing.Result, Is.EqualTo(DiscordLinkCode.DiscordLinkCodeResult.NotAvailable)); + Assert.That(created.Result, Is.EqualTo(DiscordLinkCode.DiscordLinkCodeResult.Success)); + Assert.That(created.LinkCode, Is.EqualTo("ABCD-EFGH")); + Assert.That(created.ValidMinutes, Is.EqualTo(10)); + } + + /// + /// Tests that an external chat message is only sent after the client asked for the integration info, + /// because only then it's known to understand it. + /// + [Test] + public async Task ExternalChatMessageNeedsTheClientToAskFirstAsync() + { + var (player, output) = CreatePlayer(); + var plugIn = new DiscordIntegrationViewPlugIn(player); + + var shownBefore = await plugIn.TryShowExternalChatMessageAsync(ExternalChatScope.Guild, "Hero", "hi").ConfigureAwait(false); + await plugIn.ShowDiscordIntegrationInfoAsync(DiscordIntegrationInfo.None).ConfigureAwait(false); + var shownAfter = await plugIn.TryShowExternalChatMessageAsync(ExternalChatScope.Guild, "Hero", "hi").ConfigureAwait(false); + + var packets = ReadPackets(output); + Assert.That(shownBefore, Is.False); + Assert.That(shownAfter, Is.True); + Assert.That(packets, Has.Count.EqualTo(2)); + var message = new ExternalChatMessageRef(packets[1]); + Assert.That(message.Source, Is.EqualTo(ExternalChatMessage.ExternalChatSource.Discord)); + Assert.That(message.Scope, Is.EqualTo(ExternalChatMessage.ExternalChatScope.Guild)); + Assert.That(message.SenderName, Is.EqualTo("Hero")); + Assert.That(message.Message, Is.EqualTo("hi")); + } + + /// + /// Tests that a guild message of a sender bridged from Discord reaches a supporting client as external chat + /// message, without the prefixes of the sender and the guild chat. + /// + [Test] + public async Task BridgedGuildMessageBecomesExternalChatMessageAsync() + { + var (player, output) = CreatePlayer(); + await player.InvokeViewPlugInAsync(p => p.ShowDiscordIntegrationInfoAsync(DiscordIntegrationInfo.None)).ConfigureAwait(false); + + await player.InvokeViewPlugInAsync(p => p.ChatMessageAsync("@hello guild", "@Hero", ChatMessageType.Guild)).ConfigureAwait(false); + + var message = new ExternalChatMessageRef(ReadPackets(output)[1]); + Assert.That(message.Scope, Is.EqualTo(ExternalChatMessage.ExternalChatScope.Guild)); + Assert.That(message.SenderName, Is.EqualTo("Hero")); + Assert.That(message.Message, Is.EqualTo("hello guild")); + } + + /// + /// Tests that a client which didn't ask for the integration info keeps getting the bridged message + /// as normal chat message, with the prefix in front of the sender. + /// + [Test] + public async Task BridgedGuildMessageStaysNormalChatForOtherClientsAsync() + { + var (player, output) = CreatePlayer(); + + await player.InvokeViewPlugInAsync(p => p.ChatMessageAsync("@hello guild", "@Hero", ChatMessageType.Guild)).ConfigureAwait(false); + + var message = new ChatMessageRef(ReadPackets(output)[0]); + Assert.That(message.Sender, Is.EqualTo("@Hero")); + Assert.That(message.Message, Is.EqualTo("@hello guild")); + } + + /// + /// Tests that a guild message of a character stays a normal chat message, even for a supporting client. + /// + [Test] + public async Task CharacterGuildMessageStaysNormalChatAsync() + { + var (player, output) = CreatePlayer(); + await player.InvokeViewPlugInAsync(p => p.ShowDiscordIntegrationInfoAsync(DiscordIntegrationInfo.None)).ConfigureAwait(false); + + await player.InvokeViewPlugInAsync(p => p.ChatMessageAsync("@hello guild", "Hero", ChatMessageType.Guild)).ConfigureAwait(false); + + var message = new ChatMessageRef(ReadPackets(output)[1]); + Assert.That(message.Sender, Is.EqualTo("Hero")); + } + + private static (RemotePlayer Player, MemoryStream Output) CreatePlayer() + { + var manager = new PlugInManager(null, new NullLoggerFactory(), null, null); + manager.RegisterPlugIn(); + manager.RegisterPlugIn(); + var gameContext = new Mock(); + gameContext.Setup(c => c.PersistenceContextProvider).Returns(new Mock().Object); + gameContext.Setup(c => c.Configuration).Returns(new GameConfiguration()); + gameContext.Setup(c => c.PlugInManager).Returns(manager); + gameContext.Setup(c => c.FeaturePlugIns).Returns(new FeaturePlugInContainer(manager)); + gameContext.Setup(c => c.LoggerFactory).Returns(new NullLoggerFactory()); + + var output = new MemoryStream(); + var writer = PipeWriter.Create(output, new StreamPipeWriterOptions(leaveOpen: true)); + var connection = new Mock(); + connection.SetupGet(c => c.Connected).Returns(true); + connection.SetupGet(c => c.Output).Returns(writer); + connection.SetupGet(c => c.OutputLock).Returns(new AsyncLock()); + return (new RemotePlayer(gameContext.Object, connection.Object, MuMainVersion), output); + } + + /// + /// Splits what was written to the connection into its packets, by the length in their headers. + /// + private static List ReadPackets(MemoryStream output) + { + var data = output.ToArray(); + var packets = new List(); + var offset = 0; + while (offset < data.Length) + { + var length = data[offset] switch + { + 0xC1 or 0xC3 => data[offset + 1], + _ => (data[offset + 1] << 8) | data[offset + 2], + }; + packets.Add(data[offset..(offset + length)]); + offset += length; + } + + return packets; + } +} From 6be9249d295c63b80bd47d2acf04f1ccac2be9d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 12:30:47 +0000 Subject: [PATCH 3/3] fix(discord): review follow-ups of the client integration packets - The guild and alliance chat bindings of a player are read with a targeted query (IPlayerContext.GetGuildChatBindingsAsync) instead of loading the whole binding table on every integration info request; the login notice of M5 uses it too. - A player gets a new link code at most every 30 seconds (DiscordIntegrationAction.LinkCodeCooldown). Every code costs a database write and replaces the previous one, so a client asking in a loop neither loads the database nor keeps invalidating the code the player looks at. DiscordLinkCode gets the result TooSoon for it. - Character names can't contain the prefix of bridged senders ('@'), independently of the configured name expression, so no character can pass for a Discord user in the game or in the clients. - The packet docs say what IsWorldChatBridged means: the world chat is published; the server can't tell whether the bot has a channel for it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012feEciycvL8PGjsezyRFqj --- ...-F5-03-DiscordIntegrationInfo_by-server.md | 2 +- .../C1-F5-05-DiscordLinkCode_by-server.md | 3 +- src/GameLogic/Discord/BridgedChatSender.cs | 10 ++- .../Discord/DiscordLinkCodeResult.cs | 26 ++++++++ .../Character/CreateCharacterAction.cs | 23 ++++++- .../Discord/DiscordIntegrationAction.cs | 62 +++++++++++++++++-- .../PlugIns/GuildChatBridgeNoticePlugIn.cs | 12 +--- .../Views/IDiscordIntegrationViewPlugIn.cs | 5 +- .../DiscordIntegrationViewPlugIn.cs | 12 +++- .../ServerToClient/ConnectionExtensions.cs | 2 +- .../ServerToClient/ServerToClientPackets.cs | 7 ++- .../ServerToClient/ServerToClientPackets.xml | 7 ++- .../ServerToClientPacketsRef.cs | 2 +- .../EntityFramework/PlayerContext.cs | 29 +++++++++ src/Persistence/IPlayerContext.cs | 13 ++++ .../InMemory/PlayerInMemoryContext.cs | 16 +++++ .../Discord/BridgedChatSenderTest.cs | 49 +++++++++++++++ .../Discord/DiscordIntegrationActionTest.cs | 35 +++++++++++ .../DiscordIntegrationViewPlugInTest.cs | 10 +-- .../GuildMembershipQueryTest.cs | 20 ++++++ 20 files changed, 313 insertions(+), 32 deletions(-) create mode 100644 src/GameLogic/Discord/DiscordLinkCodeResult.cs create mode 100644 tests/MUnique.OpenMU.Tests/Discord/BridgedChatSenderTest.cs diff --git a/docs/Packets/C1-F5-03-DiscordIntegrationInfo_by-server.md b/docs/Packets/C1-F5-03-DiscordIntegrationInfo_by-server.md index 09868917bd..73916c115b 100644 --- a/docs/Packets/C1-F5-03-DiscordIntegrationInfo_by-server.md +++ b/docs/Packets/C1-F5-03-DiscordIntegrationInfo_by-server.md @@ -19,7 +19,7 @@ The client configures its Discord features with it: the Rich Presence, a button | 4 | 1 | Boolean | | IsAccountLinked; Defines if the account is linked to a Discord user. | | 5 | 1 | Boolean | | IsGuildChatBridged; Defines if the chat of the guild of the character is mirrored to a Discord channel. | | 6 | 1 | Boolean | | IsAllianceChatBridged; Defines if the chat of the alliance of the character is mirrored to a Discord channel. | -| 7 | 1 | Boolean | | IsWorldChatBridged; Defines if the world chat is mirrored to a Discord channel. | +| 7 | 1 | Boolean | | IsWorldChatBridged; Defines if the world chat leaves the game for Discord: the server publishes it, so a Discord bot with a world chat channel mirrors it. The server can't tell whether the bot has such a channel. | | 8 | 20 | String | | RichPresenceApplicationId; The id of the Discord application which the client uses for the Rich Presence. Empty, if the server has none. | | 28 | 32 | String | | RichPresenceLargeImageKey; The key of the large image of the Rich Presence, as uploaded to the Discord application. Empty for no image. | | 60 | 32 | String | | RichPresenceSmallImageKey; The key of the small image of the Rich Presence, as uploaded to the Discord application. Empty for no image. | diff --git a/docs/Packets/C1-F5-05-DiscordLinkCode_by-server.md b/docs/Packets/C1-F5-05-DiscordLinkCode_by-server.md index 6e1704bc5d..d40dfb19f5 100644 --- a/docs/Packets/C1-F5-05-DiscordLinkCode_by-server.md +++ b/docs/Packets/C1-F5-05-DiscordLinkCode_by-server.md @@ -27,4 +27,5 @@ The result of a request of a code to link the account to a Discord user. | Value | Name | Description | |-------|------|-------------| | 0 | Success | The code was created. | -| 1 | NotAvailable | Linking isn't available, e.g. because the server isn't connected to Discord. | \ No newline at end of file +| 1 | NotAvailable | Linking isn't available, e.g. because the server isn't connected to Discord. | +| 2 | TooSoon | A code was requested a moment ago. Every code replaces the previous one, so the server hands out new ones only every few seconds. | \ No newline at end of file diff --git a/src/GameLogic/Discord/BridgedChatSender.cs b/src/GameLogic/Discord/BridgedChatSender.cs index 8ff0088c25..d366c2e389 100644 --- a/src/GameLogic/Discord/BridgedChatSender.cs +++ b/src/GameLogic/Discord/BridgedChatSender.cs @@ -6,7 +6,8 @@ namespace MUnique.OpenMU.GameLogic.Discord; /// /// The sender of a chat message which was written outside of the game, e.g. in Discord, and bridged into it. -/// Its name is the name of a character with a prefix, which character names can't contain, +/// Its name is the name of a character with a prefix, which character names can't contain +/// (see , enforced when a character is created), /// so it can't be mistaken for a character. /// public static class BridgedChatSender @@ -16,6 +17,13 @@ public static class BridgedChatSender /// public const char Prefix = '@'; + /// + /// Determines whether a name may be the name of a character: it must not contain the prefix. + /// + /// The name. + /// true, if a character may have the name. + public static bool IsAllowedInCharacterName(string name) => !name.Contains(Prefix, StringComparison.Ordinal); + /// /// Determines whether the sender of a chat message is a bridged sender. /// diff --git a/src/GameLogic/Discord/DiscordLinkCodeResult.cs b/src/GameLogic/Discord/DiscordLinkCodeResult.cs new file mode 100644 index 0000000000..c7ed5ac3bb --- /dev/null +++ b/src/GameLogic/Discord/DiscordLinkCodeResult.cs @@ -0,0 +1,26 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Discord; + +/// +/// The result of a request of a code to link the account to a Discord user. +/// +public enum DiscordLinkCodeResult +{ + /// + /// The code was created. + /// + Created, + + /// + /// Linking isn't available, e.g. because the Discord integration is deactivated. + /// + NotAvailable, + + /// + /// A code was requested a moment ago; see . + /// + TooSoon, +} diff --git a/src/GameLogic/PlayerActions/Character/CreateCharacterAction.cs b/src/GameLogic/PlayerActions/Character/CreateCharacterAction.cs index fc824a37e1..2d5ae8aba6 100644 --- a/src/GameLogic/PlayerActions/Character/CreateCharacterAction.cs +++ b/src/GameLogic/PlayerActions/Character/CreateCharacterAction.cs @@ -6,6 +6,7 @@ namespace MUnique.OpenMU.GameLogic.PlayerActions.Character; using System.Text.RegularExpressions; using MUnique.OpenMU.AttributeSystem; +using MUnique.OpenMU.GameLogic.Discord; using MUnique.OpenMU.GameLogic.PlugIns; using MUnique.OpenMU.GameLogic.Views.Character; @@ -69,6 +70,26 @@ private static byte[] CreateDefaultKeyConfiguration() return keyConfiguration; } + /// + /// Determines whether a character can be created with the name. + /// + /// + /// The prefix of senders bridged from Discord is never allowed, independently of the configured expression: + /// the game and the clients tell a Discord user from a character by it (). + /// + /// The name. + /// The configured regular expression of character names; null or empty for any name. + /// true, if the name is valid. + public static bool IsValidCharacterName(string name, string? nameRegex) + { + if (!BridgedChatSender.IsAllowedInCharacterName(name)) + { + return false; + } + + return string.IsNullOrWhiteSpace(nameRegex) || Regex.IsMatch(name, nameRegex); + } + private async ValueTask CreateCharacterAsync(Player player, string name, CharacterClass characterClass) { var account = player.Account; @@ -79,7 +100,7 @@ private static byte[] CreateDefaultKeyConfiguration() } player.Logger.LogDebug("Enter CreateCharacter: {0} {1} {2}", account.LoginName, name, characterClass); - var isValidName = string.IsNullOrWhiteSpace(player.GameContext.Configuration.CharacterNameRegex) || Regex.IsMatch(name, player.GameContext.Configuration.CharacterNameRegex); + var isValidName = IsValidCharacterName(name, player.GameContext.Configuration.CharacterNameRegex); player.Logger.LogDebug("CreateCharacter: Character Name matches = {0}", isValidName); if (!isValidName) { diff --git a/src/GameLogic/PlayerActions/Discord/DiscordIntegrationAction.cs b/src/GameLogic/PlayerActions/Discord/DiscordIntegrationAction.cs index 2c6902ae35..23b0409c6a 100644 --- a/src/GameLogic/PlayerActions/Discord/DiscordIntegrationAction.cs +++ b/src/GameLogic/PlayerActions/Discord/DiscordIntegrationAction.cs @@ -4,6 +4,7 @@ namespace MUnique.OpenMU.GameLogic.PlayerActions.Discord; +using System.Runtime.CompilerServices; using MUnique.OpenMU.GameLogic.AccountLinking; using MUnique.OpenMU.GameLogic.Discord; using MUnique.OpenMU.GameLogic.PlugIns; @@ -18,6 +19,29 @@ namespace MUnique.OpenMU.GameLogic.PlayerActions.Discord; /// public class DiscordIntegrationAction { + /// + /// The last time each player got a link code. Weak, so it doesn't keep players which left alive. + /// + private readonly ConditionalWeakTable> _lastLinkCodes = new(); + + private readonly TimeProvider _timeProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The time provider; the system time, if null. + public DiscordIntegrationAction(TimeProvider? timeProvider = null) + { + this._timeProvider = timeProvider ?? TimeProvider.System; + } + + /// + /// Gets the time a player has to wait between two link codes. Every code costs a database write and + /// replaces the previous one, so a client which asks in a loop neither loads the database nor keeps + /// invalidating the code the player is looking at. + /// + public static TimeSpan LinkCodeCooldown { get; } = TimeSpan.FromSeconds(30); + /// /// Shows the player how the game server is connected to Discord. /// @@ -35,13 +59,22 @@ public async ValueTask ShowIntegrationInfoAsync(Player player) public async ValueTask RequestLinkCodeAsync(Player player) { string? code = null; + var result = DiscordLinkCodeResult.NotAvailable; if (DiscordIntegrationFeaturePlugIn.GetConfiguration(player.GameContext) is not null && player.Account is { } account) { - code = await CreateLinkService(player).CreateCodeAsync(account.GetId(), AccountLinkService.DiscordProvider, player.SelectedCharacter?.Name).ConfigureAwait(false); + if (this.TryStartLinkCode(player)) + { + code = await CreateLinkService(player).CreateCodeAsync(account.GetId(), AccountLinkService.DiscordProvider, player.SelectedCharacter?.Name).ConfigureAwait(false); + result = DiscordLinkCodeResult.Created; + } + else + { + result = DiscordLinkCodeResult.TooSoon; + } } - await player.InvokeViewPlugInAsync(p => p.ShowDiscordLinkCodeAsync(code, AccountLinkService.CodeValidity)).ConfigureAwait(false); + await player.InvokeViewPlugInAsync(p => p.ShowDiscordLinkCodeAsync(result, code, AccountLinkService.CodeValidity)).ConfigureAwait(false); } /// @@ -126,11 +159,9 @@ private static AccountLinkService CreateLinkService(Player player) } using var context = player.GameContext.PersistenceContextProvider.CreateNewPlayerContext(player.GameContext.Configuration); - var bindings = await context.GetAsync().ConfigureAwait(false); var allianceId = membership.AllianceMasterGuildId ?? membership.GuildId; - var isGuildChatBridged = bindings.Any(b => b is { Scope: GuildChatScope.Guild } && b.GuildId == membership.GuildId); - var isAllianceChatBridged = bindings.Any(b => b is { Scope: GuildChatScope.Alliance } && b.GuildId == allianceId); - return (isGuildChatBridged, isAllianceChatBridged); + var bindings = await context.GetGuildChatBindingsAsync(membership.GuildId, allianceId).ConfigureAwait(false); + return (bindings.Any(b => b.Scope == GuildChatScope.Guild), bindings.Any(b => b.Scope == GuildChatScope.Alliance)); } catch (Exception ex) { @@ -149,4 +180,23 @@ private static bool IsWorldChatPublished(Player player) .OfType() .Any(p => p.Configuration?.PublishChatMessages is true); } + + /// + /// Takes the cooldown of the player, if it has passed. + /// + private bool TryStartLinkCode(Player player) + { + var now = this._timeProvider.GetUtcNow(); + var last = this._lastLinkCodes.GetValue(player, _ => new StrongBox(DateTimeOffset.MinValue)); + lock (last) + { + if (now - last.Value < LinkCodeCooldown) + { + return false; + } + + last.Value = now; + return true; + } + } } diff --git a/src/GameLogic/PlugIns/GuildChatBridgeNoticePlugIn.cs b/src/GameLogic/PlugIns/GuildChatBridgeNoticePlugIn.cs index d1c3f2ea40..bed5505408 100644 --- a/src/GameLogic/PlugIns/GuildChatBridgeNoticePlugIn.cs +++ b/src/GameLogic/PlugIns/GuildChatBridgeNoticePlugIn.cs @@ -38,18 +38,12 @@ public async ValueTask PlayerStateChangedAsync(Player player, State previousStat } using var context = player.GameContext.PersistenceContextProvider.CreateNewPlayerContext(player.GameContext.Configuration); - var bindings = await context.GetAsync().ConfigureAwait(false); var allianceId = membership.AllianceMasterGuildId ?? membership.GuildId; + var bindings = await context.GetGuildChatBindingsAsync(membership.GuildId, allianceId).ConfigureAwait(false); foreach (var binding in bindings) { - if (binding is { Scope: GuildChatScope.Guild } && binding.GuildId == membership.GuildId) - { - await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.GuildChatMirrored)).ConfigureAwait(false); - } - else if (binding is { Scope: GuildChatScope.Alliance } && binding.GuildId == allianceId) - { - await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.AllianceChatMirrored)).ConfigureAwait(false); - } + var message = binding.Scope == GuildChatScope.Guild ? nameof(PlayerMessage.GuildChatMirrored) : nameof(PlayerMessage.AllianceChatMirrored); + await player.ShowLocalizedBlueMessageAsync(message).ConfigureAwait(false); } } catch (Exception ex) diff --git a/src/GameLogic/Views/IDiscordIntegrationViewPlugIn.cs b/src/GameLogic/Views/IDiscordIntegrationViewPlugIn.cs index b6fa31e589..ad354e0787 100644 --- a/src/GameLogic/Views/IDiscordIntegrationViewPlugIn.cs +++ b/src/GameLogic/Views/IDiscordIntegrationViewPlugIn.cs @@ -21,9 +21,10 @@ public interface IDiscordIntegrationViewPlugIn : IViewPlugIn /// /// Shows the one-time code to link the account to a Discord user. /// - /// The code; null, if linking isn't available. + /// The result of the request. + /// The code; null, unless it was . /// The time the code is valid. - ValueTask ShowDiscordLinkCodeAsync(string? code, TimeSpan validity); + ValueTask ShowDiscordLinkCodeAsync(DiscordLinkCodeResult result, string? code, TimeSpan validity); /// /// Shows a chat message which was written outside of the game, if the client supports it. diff --git a/src/GameServer/RemoteView/DiscordIntegrationViewPlugIn.cs b/src/GameServer/RemoteView/DiscordIntegrationViewPlugIn.cs index a8425c1562..3543ea440d 100644 --- a/src/GameServer/RemoteView/DiscordIntegrationViewPlugIn.cs +++ b/src/GameServer/RemoteView/DiscordIntegrationViewPlugIn.cs @@ -54,11 +54,10 @@ await this._player.Connection.SendDiscordIntegrationInfoAsync( } /// - public async ValueTask ShowDiscordLinkCodeAsync(string? code, TimeSpan validity) + public async ValueTask ShowDiscordLinkCodeAsync(DiscordLinkCodeResult result, string? code, TimeSpan validity) { - var result = code is null ? DiscordLinkCode.DiscordLinkCodeResult.NotAvailable : DiscordLinkCode.DiscordLinkCodeResult.Success; var validMinutes = (byte)Math.Clamp(validity.TotalMinutes, 0, byte.MaxValue); - await this._player.Connection.SendDiscordLinkCodeAsync(result, code ?? string.Empty, validMinutes).ConfigureAwait(false); + await this._player.Connection.SendDiscordLinkCodeAsync(Convert(result), code ?? string.Empty, validMinutes).ConfigureAwait(false); } /// @@ -77,6 +76,13 @@ await this._player.Connection.SendExternalChatMessageAsync( return true; } + private static DiscordLinkCode.DiscordLinkCodeResult Convert(DiscordLinkCodeResult result) => result switch + { + DiscordLinkCodeResult.Created => DiscordLinkCode.DiscordLinkCodeResult.Success, + DiscordLinkCodeResult.TooSoon => DiscordLinkCode.DiscordLinkCodeResult.TooSoon, + _ => DiscordLinkCode.DiscordLinkCodeResult.NotAvailable, + }; + private static ExternalChatMessage.ExternalChatScope Convert(ExternalChatScope scope) => scope switch { ExternalChatScope.Guild => ExternalChatMessage.ExternalChatScope.Guild, diff --git a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs index 5a7d3f03c2..85ef3313dd 100644 --- a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs +++ b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs @@ -6641,7 +6641,7 @@ int WritePacket() /// Defines if the account is linked to a Discord user. /// Defines if the chat of the guild of the character is mirrored to a Discord channel. /// Defines if the chat of the alliance of the character is mirrored to a Discord channel. - /// Defines if the world chat is mirrored to a Discord channel. + /// Defines if the world chat leaves the game for Discord: the server publishes it, so a Discord bot with a world chat channel mirrors it. The server can't tell whether the bot has such a channel. /// The id of the Discord application which the client uses for the Rich Presence. Empty, if the server has none. /// The key of the large image of the Rich Presence, as uploaded to the Discord application. Empty for no image. /// The key of the small image of the Rich Presence, as uploaded to the Discord application. Empty for no image. diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs index b7603ff735..37ec025cbe 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs @@ -32635,7 +32635,7 @@ public bool IsAllianceChatBridged } /// - /// Gets or sets defines if the world chat is mirrored to a Discord channel. + /// Gets or sets defines if the world chat leaves the game for Discord: the server publishes it, so a Discord bot with a world chat channel mirrors it. The server can't tell whether the bot has such a channel. /// public bool IsWorldChatBridged { @@ -32724,6 +32724,11 @@ public enum DiscordLinkCodeResult /// Linking isn't available, e.g. because the server isn't connected to Discord. /// NotAvailable = 1, + + /// + /// A code was requested a moment ago. Every code replaces the previous one, so the server hands out new ones only every few seconds. + /// + TooSoon = 2, } private readonly Memory _data; diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml index 42aa684727..c1d2c0e6d5 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml @@ -11871,7 +11871,7 @@ 7 Boolean IsWorldChatBridged - Defines if the world chat is mirrored to a Discord channel. + Defines if the world chat leaves the game for Discord: the server publishes it, so a Discord bot with a world chat channel mirrors it. The server can't tell whether the bot has such a channel. 8 @@ -11955,6 +11955,11 @@ Linking isn't available, e.g. because the server isn't connected to Discord. 1 + + TooSoon + A code was requested a moment ago. Every code replaces the previous one, so the server hands out new ones only every few seconds. + 2 + diff --git a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs index 7a9b7c7025..7ebee4eb56 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs +++ b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs @@ -30756,7 +30756,7 @@ public bool IsAllianceChatBridged } /// - /// Gets or sets defines if the world chat is mirrored to a Discord channel. + /// Gets or sets defines if the world chat leaves the game for Discord: the server publishes it, so a Discord bot with a world chat channel mirrors it. The server can't tell whether the bot has such a channel. /// public bool IsWorldChatBridged { diff --git a/src/Persistence/EntityFramework/PlayerContext.cs b/src/Persistence/EntityFramework/PlayerContext.cs index 32bd700fe8..8a9eee8073 100644 --- a/src/Persistence/EntityFramework/PlayerContext.cs +++ b/src/Persistence/EntityFramework/PlayerContext.cs @@ -263,6 +263,35 @@ public async ValueTask CanSaveLetterAsync(Interfaces.LetterHeader letterHe } } + /// + public async ValueTask> GetGuildChatBindingsAsync( + Guid guildId, + Guid allianceMasterGuildId, + CancellationToken cancellationToken = default) + { + using var l = await this.LockAsync(cancellationToken).ConfigureAwait(false); + using (this.RepositoryProvider.ContextStack.UseContext(this)) + { + return await CreateGuildChatBindingsQuery(this.Context, guildId, allianceMasterGuildId) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + } + } + + /// + /// Creates the query of . + /// + /// The database context. + /// The persistent identifier of the guild. + /// The persistent identifier of the master guild of the alliance. + /// The query. + internal static IQueryable CreateGuildChatBindingsQuery(DbContext context, Guid guildId, Guid allianceMasterGuildId) + { + return context.Set() + .Where(binding => (binding.GuildId == guildId && binding.Scope == DataModel.Entities.GuildChatScope.Guild) + || (binding.GuildId == allianceMasterGuildId && binding.Scope == DataModel.Entities.GuildChatScope.Alliance)); + } + /// public async ValueTask GetAccountExternalLinkAsync( Guid accountId, diff --git a/src/Persistence/IPlayerContext.cs b/src/Persistence/IPlayerContext.cs index 4784db5b0a..c1b5774fc1 100644 --- a/src/Persistence/IPlayerContext.cs +++ b/src/Persistence/IPlayerContext.cs @@ -171,6 +171,19 @@ ValueTask> GetLatestCashShopCoinGrantsAsync( Guid accountId, CancellationToken cancellationToken = default); + /// + /// Gets the bindings of the chat of a guild and of the chat of its alliance to channels of external + /// services, without loading the bindings of other guilds. + /// + /// The persistent identifier of the guild; its binding is returned. + /// The persistent identifier of the master guild of the alliance; its binding is returned. + /// The cancellation token. + /// The bindings, at most one per scope. + ValueTask> GetGuildChatBindingsAsync( + Guid guildId, + Guid allianceMasterGuildId, + CancellationToken cancellationToken = default); + /// /// Gets the link of an account to a user of an external service. /// diff --git a/src/Persistence/InMemory/PlayerInMemoryContext.cs b/src/Persistence/InMemory/PlayerInMemoryContext.cs index da86804336..85a8e7d104 100644 --- a/src/Persistence/InMemory/PlayerInMemoryContext.cs +++ b/src/Persistence/InMemory/PlayerInMemoryContext.cs @@ -167,6 +167,22 @@ public async ValueTask CanSaveLetterAsync(Interfaces.LetterHeader letterHe return accounts.FirstOrDefault(account => account.Id == accountId)?.ChatBanUntil; } + /// + public async ValueTask> GetGuildChatBindingsAsync( + Guid guildId, + Guid allianceMasterGuildId, + CancellationToken cancellationToken = default) + { + var bindings = await this.Provider.GetRepository() + .GetAllAsync(cancellationToken) + .ConfigureAwait(false); + return bindings + .Where(binding => (binding.GuildId == guildId && binding.Scope == DataModel.Entities.GuildChatScope.Guild) + || (binding.GuildId == allianceMasterGuildId && binding.Scope == DataModel.Entities.GuildChatScope.Alliance)) + .Cast() + .ToList(); + } + /// public async ValueTask GetAccountExternalLinkAsync( Guid accountId, diff --git a/tests/MUnique.OpenMU.Tests/Discord/BridgedChatSenderTest.cs b/tests/MUnique.OpenMU.Tests/Discord/BridgedChatSenderTest.cs new file mode 100644 index 0000000000..1c0d9a00ae --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Discord/BridgedChatSenderTest.cs @@ -0,0 +1,49 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Discord; + +using MUnique.OpenMU.GameLogic.Discord; +using MUnique.OpenMU.GameLogic.PlayerActions.Character; + +/// +/// Tests for the and the guarantee it relies on. +/// +[TestFixture] +public class BridgedChatSenderTest +{ + /// + /// Tests that a character can't get the prefix of bridged senders, even without a configured name expression, + /// so no character can pass for a Discord user. + /// + /// The name. + [TestCase("@Bob")] + [TestCase("Bo@b")] + public void CharacterNameWithPrefixIsRefused(string name) + { + Assert.That(CreateCharacterAction.IsValidCharacterName(name, null), Is.False); + Assert.That(CreateCharacterAction.IsValidCharacterName(name, ".*"), Is.False); + } + + /// + /// Tests that other names still follow the configured expression. + /// + [Test] + public void OtherNamesFollowTheExpression() + { + Assert.That(CreateCharacterAction.IsValidCharacterName("Bob", null), Is.True); + Assert.That(CreateCharacterAction.IsValidCharacterName("Bob", "^[a-z]+$"), Is.False); + } + + /// + /// Tests that a bridged sender is recognized and named without its prefix. + /// + [Test] + public void BridgedSenderIsRecognized() + { + Assert.That(BridgedChatSender.IsBridged("@Hero"), Is.True); + Assert.That(BridgedChatSender.IsBridged("Hero"), Is.False); + Assert.That(BridgedChatSender.GetName("@Hero"), Is.EqualTo("Hero")); + } +} diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordIntegrationActionTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordIntegrationActionTest.cs index a47eff0119..723f104673 100644 --- a/tests/MUnique.OpenMU.Tests/Discord/DiscordIntegrationActionTest.cs +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordIntegrationActionTest.cs @@ -5,9 +5,11 @@ namespace MUnique.OpenMU.Tests.Discord; using MUnique.OpenMU.GameLogic; +using Moq; using MUnique.OpenMU.GameLogic.AccountLinking; using MUnique.OpenMU.GameLogic.Discord; using MUnique.OpenMU.GameLogic.PlayerActions.Discord; +using MUnique.OpenMU.GameLogic.Views; using MUnique.OpenMU.Persistence; /// @@ -66,6 +68,30 @@ public async Task UnlinkRemovesTheLinkAsync() Assert.That(info.LinkedUserName, Is.Null); } + /// + /// Tests that a player gets a new link code only after the cooldown, so a client asking in a loop + /// doesn't write to the database every time. + /// + [Test] + public async Task LinkCodesAreThrottledAsync() + { + var player = await CreatePlayerWithIntegrationAsync().ConfigureAwait(false); + var results = new List(); + Mock.Get(player.ViewPlugIns.GetPlugIn()!) + .Setup(v => v.ShowDiscordLinkCodeAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((result, _, _) => results.Add(result)) + .Returns(ValueTask.CompletedTask); + var time = new ManualTimeProvider(); + var action = new DiscordIntegrationAction(time); + + await action.RequestLinkCodeAsync(player).ConfigureAwait(false); + await action.RequestLinkCodeAsync(player).ConfigureAwait(false); + time.Advance(DiscordIntegrationAction.LinkCodeCooldown); + await action.RequestLinkCodeAsync(player).ConfigureAwait(false); + + Assert.That(results, Is.EqualTo(new[] { DiscordLinkCodeResult.Created, DiscordLinkCodeResult.TooSoon, DiscordLinkCodeResult.Created })); + } + private static async ValueTask CreatePlayerWithIntegrationAsync() { var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); @@ -79,4 +105,13 @@ private static AccountLinkService CreateLinkService(Player player) { return new AccountLinkService(() => player.GameContext.PersistenceContextProvider.CreateNewPlayerContext(player.GameContext.Configuration)); } + + private sealed class ManualTimeProvider : TimeProvider + { + private DateTimeOffset _now = new(2026, 10, 10, 12, 0, 0, TimeSpan.Zero); + + public override DateTimeOffset GetUtcNow() => this._now; + + public void Advance(TimeSpan duration) => this._now += duration; + } } diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordIntegrationViewPlugInTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordIntegrationViewPlugInTest.cs index c235178386..03a37b5d99 100644 --- a/tests/MUnique.OpenMU.Tests/Discord/DiscordIntegrationViewPlugInTest.cs +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordIntegrationViewPlugInTest.cs @@ -64,20 +64,22 @@ public async Task IntegrationInfoCarriesTheValuesAsync() } /// - /// Tests that a missing link code is reported as not available. + /// Tests that the results of a link code request reach the client as such. /// [Test] - public async Task MissingLinkCodeIsNotAvailableAsync() + public async Task LinkCodeResultsAreSentAsync() { var (player, output) = CreatePlayer(); var plugIn = new DiscordIntegrationViewPlugIn(player); - await plugIn.ShowDiscordLinkCodeAsync(null, TimeSpan.FromMinutes(10)).ConfigureAwait(false); - await plugIn.ShowDiscordLinkCodeAsync("ABCD-EFGH", TimeSpan.FromMinutes(10)).ConfigureAwait(false); + await plugIn.ShowDiscordLinkCodeAsync(DiscordLinkCodeResult.NotAvailable, null, TimeSpan.FromMinutes(10)).ConfigureAwait(false); + await plugIn.ShowDiscordLinkCodeAsync(DiscordLinkCodeResult.Created, "ABCD-EFGH", TimeSpan.FromMinutes(10)).ConfigureAwait(false); + await plugIn.ShowDiscordLinkCodeAsync(DiscordLinkCodeResult.TooSoon, null, TimeSpan.FromMinutes(10)).ConfigureAwait(false); var packets = ReadPackets(output); var missing = new DiscordLinkCodeRef(packets[0]); var created = new DiscordLinkCodeRef(packets[1]); + Assert.That(new DiscordLinkCodeRef(packets[2]).Result, Is.EqualTo(DiscordLinkCode.DiscordLinkCodeResult.TooSoon)); Assert.That(missing.Result, Is.EqualTo(DiscordLinkCode.DiscordLinkCodeResult.NotAvailable)); Assert.That(created.Result, Is.EqualTo(DiscordLinkCode.DiscordLinkCodeResult.Success)); Assert.That(created.LinkCode, Is.EqualTo("ABCD-EFGH")); diff --git a/tests/MUnique.OpenMU.Tests/GuildMembershipQueryTest.cs b/tests/MUnique.OpenMU.Tests/GuildMembershipQueryTest.cs index d0bb30c08e..2a01711d73 100644 --- a/tests/MUnique.OpenMU.Tests/GuildMembershipQueryTest.cs +++ b/tests/MUnique.OpenMU.Tests/GuildMembershipQueryTest.cs @@ -31,4 +31,24 @@ public void QueryIsTranslatable() Assert.That(sql, Does.Contain("JOIN")); Assert.That(sql, Does.Contain("\"AllianceGuildId\"")); } + + /// + /// Tests that the query of the chat bindings of a guild and its alliance is translated to SQL which only + /// reads their rows, instead of loading all bindings. + /// + [Test] + public void GuildChatBindingsQueryIsTranslatable() + { + if (!ConnectionConfigurator.IsInitialized) + { + ConnectionConfigurator.Initialize(new ConfigFileDatabaseConnectionStringProvider()); + } + + using var context = new AccountContext(); + + var sql = PlayerContext.CreateGuildChatBindingsQuery(context, Guid.NewGuid(), Guid.NewGuid()).ToQueryString(); + + Assert.That(sql, Does.Contain("WHERE")); + Assert.That(sql, Does.Contain("\"GuildId\"")); + } }