diff options
| author | Sho Sakuma <me@m1sk9.dev> | 2026-09-28 23:43:14 +0900 |
|---|---|---|
| committer | Sho Sakuma <me@m1sk9.dev> | 2026-09-28 23:43:14 +0900 |
| commit | 388ae4ef14ec620e6f1fe2ce8a8ea8e3a816d10d (patch) | |
| tree | 79e79355b5e407bbd6d71b3a7010c6f4348ab261 | |
| parent | a22fce70db7b43aadd8baa42e87a4430a8b1e05f (diff) | |
| download | LunaticChat-388ae4ef14ec620e6f1fe2ce8a8ea8e3a816d10d.tar.gz LunaticChat-388ae4ef14ec620e6f1fe2ce8a8ea8e3a816d10d.tar.bz2 LunaticChat-388ae4ef14ec620e6f1fe2ce8a8ea8e3a816d10d.zip | |
feat: carry the sender's world in cross-server direct messages
{world} rendered empty for a direct message from another server, which
left formats like "({world})" showing a bare pair of brackets. The relay
now carries the sender's world as an optional field, so this is a PATCH
bump (protocol 1.0.2) and either side can be updated first; an older
sender or proxy still yields an empty value.
The world is read once per message, so the sender, the spies and the
relayed copy can no longer disagree if the sender changes worlds mid-way.
Co-Authored-By: Claude <noreply@anthropic.com>
11 files changed, 94 insertions, 26 deletions
diff --git a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessage.kt b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessage.kt index 689a57c..82cff37 100644 --- a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessage.kt +++ b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessage.kt @@ -93,6 +93,8 @@ sealed interface PluginMessage { * @property targetName Target player name * @property message Message content (romaji-converted if applicable) * @property timestamp Message timestamp (milliseconds since epoch) + * @property senderWorld Name of the world the sender is in; empty when relayed by a + * pre-1.0.2 peer */ @Serializable data class DirectMessageRelay( @@ -104,6 +106,7 @@ sealed interface PluginMessage { val targetName: String, val message: String, val timestamp: Long = System.currentTimeMillis(), + val senderWorld: String = "", ) : PluginMessage /** diff --git a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolVersion.kt b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolVersion.kt index 6585c86..cdf17e7 100644 --- a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolVersion.kt +++ b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolVersion.kt @@ -33,7 +33,7 @@ package dev.m1sk9.lunaticChat.engine.protocol object ProtocolVersion { const val MAJOR = 1 const val MINOR = 0 - const val PATCH = 1 + const val PATCH = 2 /** * Minimum MINOR version this build can interoperate with (same MAJOR). diff --git a/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolBackwardCompatibilityTest.kt b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolBackwardCompatibilityTest.kt index e70edda..095ecd1 100644 --- a/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolBackwardCompatibilityTest.kt +++ b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolBackwardCompatibilityTest.kt @@ -42,6 +42,10 @@ class ProtocolBackwardCompatibilityTest { const val PRESENCE_SNAPSHOT_V1_0_1 = """{"players":[{"playerName":"Alice","serverName":"lobby"},{"playerName":"Bob","serverName":"survival"}],"timestamp":5000}""" + + // Protocol 1.0.2 snapshots — NEVER MODIFY after commit + const val DIRECT_MESSAGE_V1_0_2 = + """{"messageId":"dm-321","sourceServerName":"survival","senderId":"00000007-0000-0000-0000-000000000000","senderName":"Sender","targetServerName":"lobby","targetName":"Recipient","message":"Hello!","timestamp":6000,"senderWorld":"world_nether"}""" } private fun buildRawMessage( @@ -162,6 +166,19 @@ class ProtocolBackwardCompatibilityTest { assertEquals("Recipient", decoded.targetName) assertEquals("Hello!", decoded.message) assertEquals(4000L, decoded.timestamp) + assertEquals("", decoded.senderWorld) + } + + @Test + fun `current codec can decode protocol 1_0_2 DirectMessageRelay`() { + val data = buildRawMessage("direct_message", DIRECT_MESSAGE_V1_0_2) + val decoded = PluginMessageCodec.decode(data) + + assertIs<PluginMessage.DirectMessageRelay>(decoded) + assertEquals("dm-321", decoded.messageId) + assertEquals("Sender", decoded.senderName) + assertEquals("Hello!", decoded.message) + assertEquals("world_nether", decoded.senderWorld) } @Test diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandler.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandler.kt index af3aa3f..6c8c74a 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandler.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandler.kt @@ -141,11 +141,12 @@ class DirectMessageHandler( val displayMessage = convertIfEnabled(message, senderSettings?.japaneseConversionEnabled == true) val formats = messageFormats.current + val world = sender.world.name - notifySpies(formats, sender.name, recipient.name, message, sender.world.name) + notifySpies(formats, sender.name, recipient.name, message, world) val userMessage = - formatMessage(formats, sender.name, recipient.name, displayMessage, sender.world.name, replyTo = sender.name) + formatMessage(formats, sender.name, recipient.name, displayMessage, world, replyTo = sender.name) sender.apply { sendMessage(userMessage) takeIf { senderSettings?.directMessageNotificationEnabled == true } @@ -174,6 +175,7 @@ class DirectMessageHandler( targetName: String, targetServerName: String, message: String, + world: String, ): String { val senderSettings = settingsManager?.getSettings(sender.uniqueId) val displayMessage = convertIfEnabled(message, senderSettings?.japaneseConversionEnabled == true) @@ -181,10 +183,10 @@ class DirectMessageHandler( val formats = messageFormats.current val recipientDisplay = "$targetName@$targetServerName" - notifySpies(formats, sender.name, recipientDisplay, message, sender.world.name) + notifySpies(formats, sender.name, recipientDisplay, message, world) val userMessage = - formatMessage(formats, sender.name, recipientDisplay, displayMessage, sender.world.name, replyTo = recipientDisplay) + formatMessage(formats, sender.name, recipientDisplay, displayMessage, world, replyTo = recipientDisplay) sender.apply { sendMessage(userMessage) takeIf { senderSettings?.directMessageNotificationEnabled == true } @@ -203,14 +205,14 @@ class DirectMessageHandler( senderName: String, sourceServerName: String, message: String, + senderWorld: String, ) { val recipientSettings = settingsManager?.getSettings(recipient.uniqueId) val formats = messageFormats.current val senderDisplay = "$senderName@$sourceServerName" val userMessage = - // The relay does not carry the sender's world, so {world} renders empty for a remote sender. - formatMessage(formats, senderDisplay, recipient.name, message, world = "", replyTo = senderDisplay) + formatMessage(formats, senderDisplay, recipient.name, message, senderWorld, replyTo = senderDisplay) recipient.apply { sendMessage(userMessage) takeIf { recipientSettings?.directMessageNotificationEnabled == true } diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManager.kt index 31eb4c4..ef5f110 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManager.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManager.kt @@ -57,12 +57,14 @@ class CrossServerDirectMessageManager( val messageId = UUID.randomUUID().toString() processedMessages.markProcessed(messageId) + val world = sender.world.name val relayedMessage = directMessageHandler.handleOutgoingCrossServerMessage( sender = sender, targetName = targetName, targetServerName = targetServerName, message = message, + world = world, ) val relay = @@ -74,6 +76,7 @@ class CrossServerDirectMessageManager( targetServerName = targetServerName, targetName = targetName, message = relayedMessage, + senderWorld = world, ) sender.sendPluginMessage(plugin, PluginMessageChannel.ID, PluginMessageCodec.encode(relay)) @@ -110,6 +113,7 @@ class CrossServerDirectMessageManager( senderName = message.senderName, sourceServerName = message.sourceServerName, message = message.message, + senderWorld = message.senderWorld, ) }, ) diff --git a/platform-paper/src/main/resources/config.yml b/platform-paper/src/main/resources/config.yml index 6f9a9d9..c425c25 100644 --- a/platform-paper/src/main/resources/config.yml +++ b/platform-paper/src/main/resources/config.yml @@ -107,10 +107,10 @@ features: # {message} - The content of the message # {channel} - The name of the chat channel (only for channel chat) # {server} - The name of the server (only for Velocity cross-server chat) -# {world} - The world the sender is in (direct messages and channel chat; empty for a direct message from another server) +# {world} - The world the sender is in (direct messages and channel chat; empty for a direct message relayed by an older server or proxy) # {display_name} - The display name of the sender (only for channel chat) # {channel_id} - The ID of the chat channel (only for channel chat) -# {role} - The sender's role in the channel: Owner, Moderator or Member (only for channel chat) +# {role} - The sender's role in the channel, in the configured language (only for channel chat) # ---------------------------------------------- # These are the only settings /lc reload applies without a restart. diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandlerTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandlerTest.kt index 5c1e9ee..b6e15d5 100644 --- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandlerTest.kt +++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandlerTest.kt @@ -105,7 +105,7 @@ class DirectMessageHandlerTest { val handler = createHandler() val sender = TestUtils.createMockPlayer(name = "Alice") - runBlocking { handler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi") } + runBlocking { handler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi", "world") } val delivered = sender.receivedMessage() assertEquals("§7[§eAlice §7>> §eBob@survival§7] §fhi", delivered.legacy()) @@ -125,14 +125,14 @@ class DirectMessageHandlerTest { } @Test - fun `a direct message from another server renders the sender's world empty`() { + fun `a direct message from another server renders the world the sender is in there`() { val formats = TestUtils.createTestConfiguration().messageFormat.copy(directMessageFormat = "{sender}[{world}]: {message}") val handler = createHandler(configuration = TestUtils.createTestConfiguration().copy(messageFormat = formats)) - val recipient = TestUtils.createMockPlayer(name = "Bob") + val recipient = TestUtils.createMockPlayer(name = "Bob", worldName = "world") - handler.handleIncomingCrossServerMessage(recipient, "Alice", "lobby", "hi") + handler.handleIncomingCrossServerMessage(recipient, "Alice", "lobby", "hi", "world_nether") - assertEquals("Alice@lobby[]: hi", recipient.receivedMessage().legacy()) + assertEquals("Alice@lobby[world_nether]: hi", recipient.receivedMessage().legacy()) } @Test @@ -140,7 +140,7 @@ class DirectMessageHandlerTest { val handler = createHandler() val recipient = TestUtils.createMockPlayer(name = "Bob") - handler.handleIncomingCrossServerMessage(recipient, "Alice", "lobby", "hi") + handler.handleIncomingCrossServerMessage(recipient, "Alice", "lobby", "hi", "world") val delivered = recipient.receivedMessage() assertEquals("§7[§eAlice@lobby §7>> §eBob§7] §fhi", delivered.legacy()) @@ -220,7 +220,7 @@ class DirectMessageHandlerTest { val handler = createHandler() val sender = TestUtils.createMockPlayer(name = "Alice") - val relayed = runBlocking { handler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi") } + val relayed = runBlocking { handler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi", "world") } assertEquals("hi", relayed) } @@ -249,7 +249,7 @@ class DirectMessageHandlerTest { handler.remotePlayerRegistry = registry val recipient = TestUtils.createMockPlayer(name = "Bob") - handler.handleIncomingCrossServerMessage(recipient, "Alice", "lobby", "hi") + handler.handleIncomingCrossServerMessage(recipient, "Alice", "lobby", "hi", "world") val target = handler.getReplyTarget(recipient) assertIs<ReplyTarget.Remote>(target) diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManagerTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManagerTest.kt index fe748e1..a166006 100644 --- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManagerTest.kt +++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManagerTest.kt @@ -2,6 +2,7 @@ package dev.m1sk9.lunaticChat.paper.velocity import dev.m1sk9.lunaticChat.engine.debug.DebugLogger import dev.m1sk9.lunaticChat.engine.protocol.PluginMessage +import dev.m1sk9.lunaticChat.engine.protocol.PluginMessageCodec import dev.m1sk9.lunaticChat.paper.TestUtils import dev.m1sk9.lunaticChat.paper.chat.handler.DirectMessageHandler import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager @@ -9,6 +10,7 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.slot import io.mockk.verify import kotlinx.coroutines.runBlocking import net.kyori.adventure.text.Component @@ -16,6 +18,8 @@ import org.bukkit.plugin.Plugin import java.util.UUID import java.util.logging.Logger import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs class CrossServerDirectMessageManagerTest { private class Fixture( @@ -51,21 +55,37 @@ class CrossServerDirectMessageManagerTest { targetServerName = "survival", targetName = targetName, message = "hi", + senderWorld = "world_nether", ) @Test fun `sendCrossServerMessage relays via plugin channel and delegates display`() { val f = Fixture() - val sender = TestUtils.createMockPlayer(name = "Alice") - coEvery { f.dmHandler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi") } returns "hi" + val sender = TestUtils.createMockPlayer(name = "Alice", worldName = "world_nether") + coEvery { f.dmHandler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi", "world_nether") } returns "hi" runBlocking { f.manager.sendCrossServerMessage(sender, "Bob", "survival", "hi") } - coVerify { f.dmHandler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi") } + coVerify { f.dmHandler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi", "world_nether") } verify { sender.sendPluginMessage(f.plugin, "lunaticchat:main", any<ByteArray>()) } } @Test + fun `sendCrossServerMessage relays the world the sender is in`() { + val f = Fixture() + val sender = TestUtils.createMockPlayer(name = "Alice", worldName = "world_nether") + coEvery { f.dmHandler.handleOutgoingCrossServerMessage(any(), any(), any(), any(), any()) } returns "hi" + val sent = slot<ByteArray>() + + runBlocking { f.manager.sendCrossServerMessage(sender, "Bob", "survival", "hi") } + + verify { sender.sendPluginMessage(f.plugin, "lunaticchat:main", capture(sent)) } + val relayed = PluginMessageCodec.decode(sent.captured) + assertIs<PluginMessage.DirectMessageRelay>(relayed) + assertEquals("world_nether", relayed.senderWorld) + } + + @Test fun `handleIncomingMessage delivers to the local recipient`() { val f = Fixture() val recipient = TestUtils.createMockPlayer(name = "Bob") @@ -73,7 +93,7 @@ class CrossServerDirectMessageManagerTest { f.manager.handleIncomingMessage(relay()) - verify { f.dmHandler.handleIncomingCrossServerMessage(recipient, "Alice", "lobby", "hi") } + verify { f.dmHandler.handleIncomingCrossServerMessage(recipient, "Alice", "lobby", "hi", "world_nether") } } @Test @@ -87,7 +107,7 @@ class CrossServerDirectMessageManagerTest { f.manager.handleIncomingMessage(message) verify(exactly = 1) { - f.dmHandler.handleIncomingCrossServerMessage(any(), any(), any(), any()) + f.dmHandler.handleIncomingCrossServerMessage(any(), any(), any(), any(), any()) } } @@ -99,7 +119,7 @@ class CrossServerDirectMessageManagerTest { f.manager.handleIncomingMessage(relay(targetName = "Ghost")) verify(exactly = 0) { - f.dmHandler.handleIncomingCrossServerMessage(any(), any(), any(), any()) + f.dmHandler.handleIncomingCrossServerMessage(any(), any(), any(), any(), any()) } } @@ -144,7 +164,7 @@ class CrossServerDirectMessageManagerTest { fun `sendCrossServerMessage prunes the dedup cache when over capacity`() { val f = Fixture(cacheSize = 1) val sender = TestUtils.createMockPlayer(name = "Alice") - coEvery { f.dmHandler.handleOutgoingCrossServerMessage(any(), any(), any(), any()) } returns "hi" + coEvery { f.dmHandler.handleOutgoingCrossServerMessage(any(), any(), any(), any(), any()) } returns "hi" repeat(3) { runBlocking { f.manager.sendCrossServerMessage(sender, "Bob$it", "survival", "hi") } } diff --git a/platform-velocity/src/test/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelayTest.kt b/platform-velocity/src/test/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelayTest.kt index afbf6ca..04f2fab 100644 --- a/platform-velocity/src/test/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelayTest.kt +++ b/platform-velocity/src/test/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelayTest.kt @@ -7,12 +7,16 @@ import com.velocitypowered.api.proxy.messages.ChannelIdentifier import com.velocitypowered.api.proxy.server.RegisteredServer import com.velocitypowered.api.proxy.server.ServerInfo import dev.m1sk9.lunaticChat.engine.protocol.PluginMessage +import dev.m1sk9.lunaticChat.engine.protocol.PluginMessageCodec import io.mockk.every import io.mockk.mockk +import io.mockk.slot import io.mockk.verify import org.slf4j.Logger import java.util.Optional import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs class CrossServerDirectMessageRelayTest { private fun createRelay(): Pair<CrossServerDirectMessageRelay, ProxyServer> { @@ -57,6 +61,7 @@ class CrossServerDirectMessageRelayTest { targetName = targetName, message = "Hello!", timestamp = 1000L, + senderWorld = "world_nether", ) @Test @@ -74,6 +79,23 @@ class CrossServerDirectMessageRelayTest { } @Test + fun `relay should pass the sender's world through to the target server`() { + val (relay, proxyServer) = createRelay() + val sourceServer = createRegisteredServer("lobby") + val targetServer = createRegisteredServer("survival") + every { proxyServer.allServers } returns listOf(sourceServer, targetServer) + every { proxyServer.getPlayer("Recipient") } returns Optional.of(createPlayer("survival")) + val forwarded = slot<ByteArray>() + + relay.relay(createMessage(), sourceServer) + + verify { targetServer.sendPluginMessage(any<ChannelIdentifier>(), capture(forwarded)) } + val decoded = PluginMessageCodec.decode(forwarded.captured) + assertIs<PluginMessage.DirectMessageRelay>(decoded) + assertEquals("world_nether", decoded.senderWorld) + } + + @Test fun `relay should return SERVER_NOT_FOUND error when target server missing`() { val (relay, proxyServer) = createRelay() val sourceServer = createRegisteredServer("lobby") diff --git a/website/src/docs/reference/message-format.md b/website/src/docs/reference/message-format.md index b0976fa..61496b8 100644 --- a/website/src/docs/reference/message-format.md +++ b/website/src/docs/reference/message-format.md @@ -17,7 +17,7 @@ These are the only settings `/lc reload` <Badge type="tip" text="v1.4.0~" /> app | `{message}` | Message content | All | | `{channel}` | Channel name | `channelMessageFormat` | | `{server}` | Server name | `crossServerGlobalChatFormat` | -| `{world}` <Badge type="tip" text="v1.5.0~" /> | World the sender is in. Empty for a direct message from another server | `directMessageFormat`, `channelMessageFormat` | +| `{world}` <Badge type="tip" text="v1.5.0~" /> | World the sender is in. For a direct message from another server, empty unless the sending server runs Paper v1.5.0+ and the proxy runs Velocity v1.3.1+ | `directMessageFormat`, `channelMessageFormat` | | `{display_name}` <Badge type="tip" text="v1.5.0~" /> | Display name of the sender, including colors set by other plugins | `channelMessageFormat` | | `{channel_id}` <Badge type="tip" text="v1.5.0~" /> | Channel ID | `channelMessageFormat` | | `{role}` <Badge type="tip" text="v1.5.0~" /> | Sender's role in the channel (Owner / Moderator / Member) | `channelMessageFormat` | diff --git a/website/src/ja/docs/reference/message-format.md b/website/src/ja/docs/reference/message-format.md index 79c6745..9e98bf8 100644 --- a/website/src/ja/docs/reference/message-format.md +++ b/website/src/ja/docs/reference/message-format.md @@ -17,7 +17,7 @@ layout: doc | `{message}` | メッセージの内容 | すべて | | `{channel}` | チャンネル名 | `channelMessageFormat` | | `{server}` | サーバー名 | `crossServerGlobalChatFormat` | -| `{world}` <Badge type="tip" text="v1.5.0~" /> | 送信者がいるワールド名.別サーバーから届いたダイレクトメッセージでは空になります | `directMessageFormat`, `channelMessageFormat` | +| `{world}` <Badge type="tip" text="v1.5.0~" /> | 送信者がいるワールド名.別サーバーから届いたダイレクトメッセージでは,送信側が Paper v1.5.0 以降かつ Velocity v1.3.1 以降でないと空になります | `directMessageFormat`, `channelMessageFormat` | | `{display_name}` <Badge type="tip" text="v1.5.0~" /> | 送信者の表示名.他のプラグインが設定した色も含みます | `channelMessageFormat` | | `{channel_id}` <Badge type="tip" text="v1.5.0~" /> | チャンネル ID | `channelMessageFormat` | | `{role}` <Badge type="tip" text="v1.5.0~" /> | チャンネル内での送信者の役割 (オーナー / モデレーター / メンバー) | `channelMessageFormat` | |
