From d0a14e074820b7b32e13217d60b26b89475013db Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:57:31 +0000 Subject: [PATCH 1/3] feat(chat): show chat messages from Discord as such Groundwork for #700 that needs no server support yet: the display path the ExternalChatMessage packet handler will call (UI::Chat::External::Show). - The sender is shown as "[Discord] ", with the full Discord name (up to 32 characters) instead of the 10-byte character name. - The message takes its scope's chat type and colours (guild, alliance, world) and is also listed in a new Discord view of the chat log. - Discord users can't be whispered: no whisper allow-list entry, no speech balloon, and right-clicking their line doesn't start a whisper. - F2 cycles all -> whispers -> Discord -> all; the Discord view is only offered once a message from Discord arrived, so nothing changes on servers without a bridge. The repeated create-and-append blocks of CNewUIChatLogWindow::ProcessAddText are extracted into AppendMessage, and the copy into a second view (an error's origin, now also the Discord view) into GetSecondaryMsgs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012feEciycvL8PGjsezyRFqj --- docs/discord.md | 17 ++ src/source/UI/Chat/ExternalChat.cpp | 59 ++++++ src/source/UI/Chat/ExternalChat.h | 53 +++++ .../UI/NewUI/HUD/NewUIChatLogWindow.cpp | 198 +++++++++--------- src/source/UI/NewUI/HUD/NewUIChatLogWindow.h | 34 ++- .../UI/NewUI/Widgets/NewUIChatInputBox.cpp | 12 +- .../UI/NewUI/Widgets/NewUIChatInputBox.h | 1 - tests/ui/CMakeLists.txt | 10 + tests/ui/test_external_chat.cpp | 98 +++++++++ 9 files changed, 363 insertions(+), 119 deletions(-) create mode 100644 src/source/UI/Chat/ExternalChat.cpp create mode 100644 src/source/UI/Chat/ExternalChat.h create mode 100644 tests/ui/test_external_chat.cpp diff --git a/docs/discord.md b/docs/discord.md index ed8dd108ee..4bf38fa319 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -48,6 +48,23 @@ The options window has a **Discord** setting: The setting applies immediately and is stored in `config.ini`. +## Messages from Discord + +> Requires a server whose Discord chat bridge sends them to the client as +> such. Without it, nothing in this section appears; against older servers, +> messages from Discord arrive as ordinary chat with a name prefix. + +A message written in a Discord channel that the server bridges into the game +appears in the chat of its scope - guild, alliance or world - with that chat's +colours. Its sender is shown as `[Discord] `, with the full Discord name +(up to 32 characters) instead of a character name. + +- Discord users are not characters: right-clicking their line does not start a + whisper, and they are not added to your whisper list. +- **F2** switches the chat log between all messages and whispers. Once a + message from Discord has arrived, it also offers a view with only the + messages from Discord: all → whispers → Discord → all. + ## For server operators Each server uses its own Discord application, so the name your players see diff --git a/src/source/UI/Chat/ExternalChat.cpp b/src/source/UI/Chat/ExternalChat.cpp new file mode 100644 index 0000000000..5dd85be2ba --- /dev/null +++ b/src/source/UI/Chat/ExternalChat.cpp @@ -0,0 +1,59 @@ +#include "stdafx.h" +#include "UI/Chat/ExternalChat.h" + +#include "App/Control/ControlTaps.h" +#include "Network/Server/WSclient.h" +#include "UI/NewUI/NewUISystem.h" + +namespace +{ +constexpr const wchar_t* DiscordBadge = L"[Discord] "; +constexpr const char* DiscordChatKind = "discord"; +} // namespace + +namespace UI::Chat::External +{ +std::wstring SenderLabel(Source source, std::wstring_view displayName) +{ + switch (source) + { + case Source::Discord: + default: + return DiscordBadge + std::wstring(displayName.substr(0, MaxDisplayNameLength)); + } +} + +SEASON3B::MESSAGE_TYPE ScopeMessageType(Scope scope) +{ + switch (scope) + { + case Scope::Guild: + return SEASON3B::TYPE_GUILD_MESSAGE; + case Scope::Alliance: + return SEASON3B::TYPE_UNION_MESSAGE; + case Scope::World: + default: + return SEASON3B::TYPE_CHAT_MESSAGE; + } +} + +std::wstring ShownText(std::wstring_view text) +{ + return std::wstring(text.substr(0, MAX_CHAT_SIZE)); +} + +// Unlike an in-game message, no speech balloon (nobody stands there) and no +// entry in the whisper allow-list (nobody to whisper). +void Show(const Message& message) +{ + const std::wstring sender = SenderLabel(message.source, message.displayName); + const std::wstring text = ShownText(message.text); + if (text.empty()) + { + return; + } + + g_pChatListBox->AddExternalText(sender, text, ScopeMessageType(message.scope)); + App::Control::Events::RecordChatLine(sender.c_str(), text.c_str(), DiscordChatKind); +} +} // namespace UI::Chat::External diff --git a/src/source/UI/Chat/ExternalChat.h b/src/source/UI/Chat/ExternalChat.h new file mode 100644 index 0000000000..670e508a58 --- /dev/null +++ b/src/source/UI/Chat/ExternalChat.h @@ -0,0 +1,53 @@ +// Chat messages written outside the game - today in a Discord channel that +// the server bridges into guild, alliance or world chat (#700). +// +// They are shown as such: the sender is marked with where they wrote, keeps +// a name longer than a character's, and can't be whispered, because there +// is no character behind it. +#pragma once + +#include "UI/NewUI/HUD/NewUIChatLogWindow.h" + +#include +#include +#include + +namespace UI::Chat::External +{ +enum class Source +{ + Discord, +}; + +// Which in-game chat the message belongs to; it takes that chat's colours. +enum class Scope +{ + Guild, + Alliance, + World, +}; + +// Discord's display names are at most 32 characters long. +inline constexpr std::size_t MaxDisplayNameLength = 32; + +struct Message +{ + Source source = Source::Discord; + Scope scope = Scope::World; + std::wstring displayName; + std::wstring text; +}; + +// What the chat log shows as the sender: "[Discord] name", the name cut to +// MaxDisplayNameLength characters. +[[nodiscard]] std::wstring SenderLabel(Source source, std::wstring_view displayName); + +// The chat log type whose colours the scope uses. +[[nodiscard]] SEASON3B::MESSAGE_TYPE ScopeMessageType(Scope scope); + +// The text as shown: cut to the length of an in-game chat message. +[[nodiscard]] std::wstring ShownText(std::wstring_view text); + +// Shows the message in the chat log, in its scope and in the Discord view. +void Show(const Message& message); +} // namespace UI::Chat::External diff --git a/src/source/UI/NewUI/HUD/NewUIChatLogWindow.cpp b/src/source/UI/NewUI/HUD/NewUIChatLogWindow.cpp index c202c37ce0..6293dfa8c5 100644 --- a/src/source/UI/NewUI/HUD/NewUIChatLogWindow.cpp +++ b/src/source/UI/NewUI/HUD/NewUIChatLogWindow.cpp @@ -267,6 +267,18 @@ void SEASON3B::CNewUIChatLogWindow::SetPosition(int x, int y) } void SEASON3B::CNewUIChatLogWindow::AddText(const type_string& strID, const type_string& strText, MESSAGE_TYPE MsgType, MESSAGE_TYPE ErrMsgType /*= TYPE_ALL_MESSAGE*/) +{ + AddTextToViews(strID, strText, MsgType, ErrMsgType, false); +} + +void SEASON3B::CNewUIChatLogWindow::AddExternalText(const type_string& strSender, const type_string& strText, + MESSAGE_TYPE MsgType) +{ + AddTextToViews(strSender, strText, MsgType, TYPE_ALL_MESSAGE, true); +} + +void SEASON3B::CNewUIChatLogWindow::AddTextToViews(const type_string& strID, const type_string& strText, + MESSAGE_TYPE MsgType, MESSAGE_TYPE ErrMsgType, bool bExternal) { if (strID.empty() && strText.empty()) { @@ -283,19 +295,24 @@ void SEASON3B::CNewUIChatLogWindow::AddText(const type_string& strID, const type RemoveFrontLine(TYPE_ALL_MESSAGE); } + if (bExternal && GetNumberOfLines(TYPE_DISCORD_MESSAGE) >= MAX_NUMBER_OF_LINES) + { + RemoveFrontLine(TYPE_DISCORD_MESSAGE); + } + if (m_vecFilters.empty()) { - ProcessAddText(strID, strText, MsgType, ErrMsgType); + ProcessAddText(strID, strText, MsgType, ErrMsgType, bExternal); } else { if (MsgType != TYPE_CHAT_MESSAGE) { - ProcessAddText(strID, strText, MsgType, ErrMsgType); + ProcessAddText(strID, strText, MsgType, ErrMsgType, bExternal); } else if (CheckFilterText(strID) || CheckFilterText(strText)) { - ProcessAddText(strID, strText, MsgType, ErrMsgType); + ProcessAddText(strID, strText, MsgType, ErrMsgType, bExternal); if (g_pOption->IsWhisperSound()) { @@ -305,7 +322,45 @@ void SEASON3B::CNewUIChatLogWindow::AddText(const type_string& strID, const type } } -void SEASON3B::CNewUIChatLogWindow::ProcessAddText(const type_string& strID, const type_string& strText, MESSAGE_TYPE MsgType, MESSAGE_TYPE ErrMsgType) +void SEASON3B::CNewUIChatLogWindow::AppendMessage(type_vector_msgs* pvecMsgs, const type_string& strID, + const type_string& strText, MESSAGE_TYPE MsgType, bool bExternal) +{ + const auto pMsgText = new CMessageText; + if (!pMsgText->Create(strID, strText, MsgType, bExternal)) + { + delete pMsgText; + return; + } + pvecMsgs->push_back(pMsgText); +} + +SEASON3B::CNewUIChatLogWindow::type_vector_msgs* +SEASON3B::CNewUIChatLogWindow::GetSecondaryMsgs(MESSAGE_TYPE MsgType, MESSAGE_TYPE ErrMsgType, bool bExternal) +{ + if (bExternal) + { + return &m_vecDiscordMsgs; + } + + // An error caused by, say, a whisper is listed among the whispers too. + if (MsgType == TYPE_ERROR_MESSAGE && ErrMsgType != TYPE_ERROR_MESSAGE && ErrMsgType != TYPE_ALL_MESSAGE) + { + type_vector_msgs* pErrvecMsgs = GetMsgs(ErrMsgType); + assert(pErrvecMsgs != nullptr && "Error chat"); + return pErrvecMsgs; + } + + return nullptr; +} + +bool SEASON3B::CNewUIChatLogWindow::IsInCurrentView(MESSAGE_TYPE MsgType, bool bExternal) const +{ + const MESSAGE_TYPE current = GetCurrentMsgType(); + return current == TYPE_ALL_MESSAGE || current == MsgType || (bExternal && current == TYPE_DISCORD_MESSAGE); +} + +void SEASON3B::CNewUIChatLogWindow::ProcessAddText(const type_string& strID, const type_string& strText, + MESSAGE_TYPE MsgType, MESSAGE_TYPE ErrMsgType, bool bExternal) { type_vector_msgs* pvecMsgs = GetMsgs(MsgType); if (pvecMsgs == nullptr) @@ -314,6 +369,8 @@ void SEASON3B::CNewUIChatLogWindow::ProcessAddText(const type_string& strID, con return; } + type_vector_msgs* pSecondaryMsgs = GetSecondaryMsgs(MsgType, ErrMsgType, bExternal); + int nScrollLines = 0; if (strText.size() >= 20) { @@ -321,84 +378,28 @@ void SEASON3B::CNewUIChatLogWindow::ProcessAddText(const type_string& strID, con SeparateText(strID, strText, MsgType, strText1, strText2); if (!strText1.empty()) { - const auto pMsgText = new CMessageText; - if (!pMsgText->Create(strID, strText, MsgType)) - delete pMsgText; - else - { - pvecMsgs->push_back(pMsgText); - } - - const auto pAllMsgText = new CMessageText; - if (!pAllMsgText->Create(strID, strText1, MsgType)) - { - delete pAllMsgText; - } - else - { - m_vecAllMsgs.push_back(pAllMsgText); - } - - if ((MsgType == TYPE_ERROR_MESSAGE) && (ErrMsgType != TYPE_ERROR_MESSAGE && ErrMsgType != TYPE_ALL_MESSAGE)) + AppendMessage(pvecMsgs, strID, strText, MsgType, bExternal); + AppendMessage(&m_vecAllMsgs, strID, strText1, MsgType, bExternal); + if (pSecondaryMsgs != nullptr) { - type_vector_msgs* pErrvecMsgs = GetMsgs(ErrMsgType); - if (pErrvecMsgs == nullptr) - { - assert(!"Error Chat"); - return; - } - - const auto pErrMsgText = new CMessageText; - if (!pErrMsgText->Create(strID, strText1, MsgType)) - delete pErrMsgText; - else - { - pErrvecMsgs->push_back(pErrMsgText); - } + AppendMessage(pSecondaryMsgs, strID, strText1, MsgType, bExternal); } - if (GetCurrentMsgType() == TYPE_ALL_MESSAGE || GetCurrentMsgType() == MsgType) + if (IsInCurrentView(MsgType, bExternal)) { nScrollLines++; } } if (!strText2.empty()) { - const auto pMsgText = new CMessageText; - if (!pMsgText->Create(L"", strText2, MsgType)) - delete pMsgText; - else - { - pvecMsgs->push_back(pMsgText); - } - - const auto pAllMsgText = new CMessageText; - if (!pAllMsgText->Create(L"", strText2, MsgType)) - delete pAllMsgText; - else + AppendMessage(pvecMsgs, L"", strText2, MsgType, bExternal); + AppendMessage(&m_vecAllMsgs, L"", strText2, MsgType, bExternal); + if (pSecondaryMsgs != nullptr) { - m_vecAllMsgs.push_back(pAllMsgText); - } - - if ((MsgType == TYPE_ERROR_MESSAGE) && (ErrMsgType != TYPE_ERROR_MESSAGE && ErrMsgType != TYPE_ALL_MESSAGE)) - { - type_vector_msgs* pErrvecMsgs = GetMsgs(ErrMsgType); - if (pErrvecMsgs == nullptr) - { - assert(!"Error chat 2"); - return; - } - - const auto pErrMsgText = new CMessageText; - if (!pErrMsgText->Create(L"", strText2, MsgType)) - delete pErrMsgText; - else - { - pErrvecMsgs->push_back(pErrMsgText); - } + AppendMessage(pSecondaryMsgs, L"", strText2, MsgType, bExternal); } - if (GetCurrentMsgType() == TYPE_ALL_MESSAGE || GetCurrentMsgType() == MsgType) + if (IsInCurrentView(MsgType, bExternal)) { nScrollLines++; } @@ -406,42 +407,14 @@ void SEASON3B::CNewUIChatLogWindow::ProcessAddText(const type_string& strID, con } else { - const auto pMsgText = new CMessageText; - if (!pMsgText->Create(strID, strText, MsgType)) - delete pMsgText; - else + AppendMessage(pvecMsgs, strID, strText, MsgType, bExternal); + AppendMessage(&m_vecAllMsgs, strID, strText, MsgType, bExternal); + if (pSecondaryMsgs != nullptr) { - pvecMsgs->push_back(pMsgText); + AppendMessage(pSecondaryMsgs, strID, strText, MsgType, bExternal); } - const auto pAllMsgText = new CMessageText; - if (!pAllMsgText->Create(strID, strText, MsgType)) - delete pAllMsgText; - else - { - m_vecAllMsgs.push_back(pAllMsgText); - } - - if ((MsgType == TYPE_ERROR_MESSAGE) - && (ErrMsgType != TYPE_ERROR_MESSAGE && ErrMsgType != TYPE_ALL_MESSAGE)) - { - type_vector_msgs* pErrvecMsgs = GetMsgs(ErrMsgType); - if (pErrvecMsgs == nullptr) - { - assert(!"Error chat 3"); - return; - } - - const auto pErrMsgText = new CMessageText; - if (!pErrMsgText->Create(strID, strText, MsgType)) - delete pErrMsgText; - else - { - pErrvecMsgs->push_back(pErrMsgText); - } - } - - if (GetCurrentMsgType() == TYPE_ALL_MESSAGE || GetCurrentMsgType() == MsgType) + if (IsInCurrentView(MsgType, bExternal)) { nScrollLines++; } @@ -729,7 +702,7 @@ bool SEASON3B::CNewUIChatLogWindow::UpdateMouseEvent() m_iPointedMessageIndex = i; std::wstring strID = pMsgText->GetID(); - if (SEASON3B::IsPress(VK_RBUTTON) && strID.empty() == false) + if (SEASON3B::IsPress(VK_RBUTTON) && strID.empty() == false && !pMsgText->IsExternal()) { g_pChatInputBox->SetWhsprID(strID.c_str()); } @@ -1025,6 +998,8 @@ SEASON3B::CNewUIChatLogWindow::type_vector_msgs* SEASON3B::CNewUIChatLogWindow:: return &m_vecGensMsgs; case TYPE_GM_MESSAGE: return &m_vecGMMsgs; + case TYPE_DISCORD_MESSAGE: + return &m_vecDiscordMsgs; } return nullptr; @@ -1048,6 +1023,21 @@ SEASON3B::MESSAGE_TYPE SEASON3B::CNewUIChatLogWindow::GetCurrentMsgType() const return m_CurrentRenderMsgType; } +// Players on a server without a Discord chat bridge never get the Discord +// view, so F2 keeps toggling between all messages and whispers for them. +SEASON3B::MESSAGE_TYPE SEASON3B::CNewUIChatLogWindow::GetNextView() const +{ + switch (GetCurrentMsgType()) + { + case TYPE_ALL_MESSAGE: + return TYPE_WHISPER_MESSAGE; + case TYPE_WHISPER_MESSAGE: + return m_vecDiscordMsgs.empty() ? TYPE_ALL_MESSAGE : TYPE_DISCORD_MESSAGE; + default: + return TYPE_ALL_MESSAGE; + } +} + void SEASON3B::CNewUIChatLogWindow::ShowChatLog() { m_bShowChatLog = true; diff --git a/src/source/UI/NewUI/HUD/NewUIChatLogWindow.h b/src/source/UI/NewUI/HUD/NewUIChatLogWindow.h index cc12b53822..d0e9767a20 100644 --- a/src/source/UI/NewUI/HUD/NewUIChatLogWindow.h +++ b/src/source/UI/NewUI/HUD/NewUIChatLogWindow.h @@ -27,6 +27,9 @@ namespace SEASON3B TYPE_UNION_MESSAGE, TYPE_GENS_MESSAGE, TYPE_GM_MESSAGE, + // A view, not a message type: the messages from Discord. Each of them + // keeps the type of its scope (guild, alliance, chat) for its colours. + TYPE_DISCORD_MESSAGE, NUMBER_OF_TYPES, TYPE_UNKNOWN = 0xFFFFFFFF @@ -40,12 +43,13 @@ namespace SEASON3B type_string m_strID, m_strText; MESSAGE_TYPE m_MsgType; DWORD m_dwIndentSize; + bool m_bExternal; public: - TMessageText() : m_MsgType(TYPE_UNKNOWN), m_dwIndentSize(0) {} + TMessageText() : m_MsgType(TYPE_UNKNOWN), m_dwIndentSize(0), m_bExternal(false) {} ~TMessageText() { Release(); } - bool Create(const type_string& strID, const type_string& strText, MESSAGE_TYPE MsgType) + bool Create(const type_string& strID, const type_string& strText, MESSAGE_TYPE MsgType, bool bExternal = false) { if (MsgType >= NUMBER_OF_TYPES) return false; @@ -53,6 +57,7 @@ namespace SEASON3B m_strID = strID; m_strText = strText; m_MsgType = MsgType; + m_bExternal = bExternal; return true; } @@ -61,11 +66,18 @@ namespace SEASON3B m_strID.resize(0); m_strText.resize(0); m_MsgType = TYPE_UNKNOWN; + m_bExternal = false; } const type_string& GetID() const { return m_strID; } const type_string& GetText() const { return m_strText; } MESSAGE_TYPE GetType() const { return m_MsgType; } + // Written outside the game (in Discord): its sender is no character, + // so it can't be whispered. + bool IsExternal() const + { + return m_bExternal; + } }; typedef TMessageText CMessageText; @@ -124,6 +136,7 @@ namespace SEASON3B type_vector_msgs m_VecSystemMsgs; type_vector_msgs m_vecErrorMsgs; type_vector_msgs m_vecGMMsgs; + type_vector_msgs m_vecDiscordMsgs; type_vector_filters m_vecFilters; POINT m_WndPos, m_ScrollBtnPos; @@ -159,6 +172,9 @@ namespace SEASON3B void SetPosition(int x, int y); void AddText(const type_string& strID, const type_string& strText, MESSAGE_TYPE MsgType, MESSAGE_TYPE ErrMsgType = TYPE_ALL_MESSAGE); + // A message written outside the game, in the given scope's type. It is + // also listed in the TYPE_DISCORD_MESSAGE view. + void AddExternalText(const type_string& strSender, const type_string& strText, MESSAGE_TYPE MsgType); void RemoveFrontLine(MESSAGE_TYPE MsgType); void Clear(MESSAGE_TYPE MsgType); void ClearAll(); @@ -167,6 +183,9 @@ namespace SEASON3B MESSAGE_TYPE GetCurrentMsgType() const; void ChangeMessage(MESSAGE_TYPE MsgType); + // The view F2 switches to from the current one: all, whispers, and + // Discord once a message from Discord has arrived. + MESSAGE_TYPE GetNextView() const; void ShowChatLog(); void HideChatLog(); @@ -201,7 +220,16 @@ namespace SEASON3B protected: type_vector_msgs* GetMsgs(MESSAGE_TYPE MsgType); - void ProcessAddText(const type_string& strID, const type_string& strText, MESSAGE_TYPE MsgType, MESSAGE_TYPE ErrMsgType); + void AddTextToViews(const type_string& strID, const type_string& strText, MESSAGE_TYPE MsgType, + MESSAGE_TYPE ErrMsgType, bool bExternal); + void ProcessAddText(const type_string& strID, const type_string& strText, MESSAGE_TYPE MsgType, + MESSAGE_TYPE ErrMsgType, bool bExternal); + void AppendMessage(type_vector_msgs* pvecMsgs, const type_string& strID, const type_string& strText, + MESSAGE_TYPE MsgType, bool bExternal); + // The view a message is copied to besides its own and "all": the + // error's origin, or the Discord view. Null for none. + type_vector_msgs* GetSecondaryMsgs(MESSAGE_TYPE MsgType, MESSAGE_TYPE ErrMsgType, bool bExternal); + bool IsInCurrentView(MESSAGE_TYPE MsgType, bool bExternal) const; void SeparateText(IN const type_string& strID, IN const type_string& strText, MESSAGE_TYPE MsgType, OUT type_string& strText1, OUT type_string& strText2); diff --git a/src/source/UI/NewUI/Widgets/NewUIChatInputBox.cpp b/src/source/UI/NewUI/Widgets/NewUIChatInputBox.cpp index e010d36669..e12b772e43 100644 --- a/src/source/UI/NewUI/Widgets/NewUIChatInputBox.cpp +++ b/src/source/UI/NewUI/Widgets/NewUIChatInputBox.cpp @@ -52,7 +52,6 @@ void SEASON3B::CNewUIChatInputBox::Init() m_bShowChatLog = true; m_bWhisperSend = true; - m_bShowMessageElseNormal = false; } void SEASON3B::CNewUIChatInputBox::LoadImages() @@ -389,16 +388,7 @@ bool SEASON3B::CNewUIChatInputBox::UpdateKeyEvent() { if (SEASON3B::IsPress(VK_F2)) { - m_bShowMessageElseNormal = !m_bShowMessageElseNormal; - - if (m_bShowMessageElseNormal) - { - m_pNewUIChatLogWnd->ChangeMessage(SEASON3B::TYPE_WHISPER_MESSAGE); - } - else - { - m_pNewUIChatLogWnd->ChangeMessage(SEASON3B::TYPE_ALL_MESSAGE); - } + m_pNewUIChatLogWnd->ChangeMessage(m_pNewUIChatLogWnd->GetNextView()); PlayBuffer(SOUND_CLICK01); return false; diff --git a/src/source/UI/NewUI/Widgets/NewUIChatInputBox.h b/src/source/UI/NewUI/Widgets/NewUIChatInputBox.h index 8b22d78b2e..55f212b07e 100644 --- a/src/source/UI/NewUI/Widgets/NewUIChatInputBox.h +++ b/src/source/UI/NewUI/Widgets/NewUIChatInputBox.h @@ -115,7 +115,6 @@ namespace SEASON3B bool m_bShowSystemMessages; bool m_bShowChatLog; bool m_bWhisperSend; - bool m_bShowMessageElseNormal; CNewUIButton m_BtnSize; CNewUIButton m_BtnTransparency; diff --git a/tests/ui/CMakeLists.txt b/tests/ui/CMakeLists.txt index 5eb2f5d6bf..4eb51c0d63 100644 --- a/tests/ui/CMakeLists.txt +++ b/tests/ui/CMakeLists.txt @@ -4,6 +4,16 @@ mu_add_test( LINK_LIBS MuClient ) +# The chat log pulls in client globals defined in Winmain.cpp, so it is linked +# here (same as test_audio_playback). +mu_add_test( + NAME external_chat_tests + SOURCES + test_external_chat.cpp + ${CMAKE_SOURCE_DIR}/src/source/App/Platform/Windows/Winmain.cpp + LINK_LIBS MuClient +) + mu_add_test( NAME text_search_tests SOURCES diff --git a/tests/ui/test_external_chat.cpp b/tests/ui/test_external_chat.cpp new file mode 100644 index 0000000000..f1c681d873 --- /dev/null +++ b/tests/ui/test_external_chat.cpp @@ -0,0 +1,98 @@ +// doctest unit tests for chat messages written outside the game (Discord, +// #700): how their sender is shown, which chat they belong to, and the chat +// log's Discord view. +// +// Run: ctest --test-dir --build-config Release -R "External chat" + +#include "App/stdafx.h" +#include "doctest.h" + +#include "UI/Chat/ExternalChat.h" +#include "UI/NewUI/HUD/NewUIChatLogWindow.h" + +#include + +using namespace UI::Chat::External; +using SEASON3B::CNewUIChatLogWindow; + +namespace +{ +// Short enough that the chat log keeps it on one line without measuring it. +constexpr const wchar_t* ShortText = L"hi guild"; +} // namespace + +TEST_CASE("External chat marks the sender with where they wrote [ui][chat]") +{ + CHECK(SenderLabel(Source::Discord, L"Sven") == L"[Discord] Sven"); +} + +TEST_CASE("External chat keeps a Discord name longer than a character's, up to its limit [ui][chat]") +{ + const std::wstring longName(MaxDisplayNameLength + 8, L'n'); + CHECK(SenderLabel(Source::Discord, longName) == L"[Discord] " + longName.substr(0, MaxDisplayNameLength)); +} + +TEST_CASE("External chat takes the colours of its scope [ui][chat]") +{ + CHECK(ScopeMessageType(Scope::Guild) == SEASON3B::TYPE_GUILD_MESSAGE); + CHECK(ScopeMessageType(Scope::Alliance) == SEASON3B::TYPE_UNION_MESSAGE); + CHECK(ScopeMessageType(Scope::World) == SEASON3B::TYPE_CHAT_MESSAGE); +} + +TEST_CASE("External chat cuts the text to an in-game chat message [ui][chat]") +{ + const std::wstring longText(500, L'x'); + CHECK(ShownText(longText).size() < longText.size()); + CHECK(ShownText(ShortText) == ShortText); +} + +TEST_CASE("External chat is listed in its scope, among all messages and in the Discord view [ui][chat]") +{ + CNewUIChatLogWindow chatLog; + chatLog.AddExternalText(SenderLabel(Source::Discord, L"Sven"), ShortText, SEASON3B::TYPE_GUILD_MESSAGE); + + CHECK(chatLog.GetNumberOfLines(SEASON3B::TYPE_GUILD_MESSAGE) == 1); + CHECK(chatLog.GetNumberOfLines(SEASON3B::TYPE_ALL_MESSAGE) == 1); + CHECK(chatLog.GetNumberOfLines(SEASON3B::TYPE_DISCORD_MESSAGE) == 1); +} + +TEST_CASE("In-game chat stays out of the Discord view [ui][chat]") +{ + CNewUIChatLogWindow chatLog; + chatLog.AddText(L"Player", ShortText, SEASON3B::TYPE_GUILD_MESSAGE); + + CHECK(chatLog.GetNumberOfLines(SEASON3B::TYPE_GUILD_MESSAGE) == 1); + CHECK(chatLog.GetNumberOfLines(SEASON3B::TYPE_DISCORD_MESSAGE) == 0); +} + +TEST_CASE("F2 toggles all messages and whispers while nothing came from Discord [ui][chat]") +{ + CNewUIChatLogWindow chatLog; + CHECK(chatLog.GetNextView() == SEASON3B::TYPE_WHISPER_MESSAGE); + + chatLog.ChangeMessage(SEASON3B::TYPE_WHISPER_MESSAGE); + CHECK(chatLog.GetNextView() == SEASON3B::TYPE_ALL_MESSAGE); +} + +TEST_CASE("F2 also offers the Discord view once something came from Discord [ui][chat]") +{ + CNewUIChatLogWindow chatLog; + chatLog.AddExternalText(SenderLabel(Source::Discord, L"Sven"), ShortText, SEASON3B::TYPE_CHAT_MESSAGE); + + chatLog.ChangeMessage(SEASON3B::TYPE_WHISPER_MESSAGE); + CHECK(chatLog.GetNextView() == SEASON3B::TYPE_DISCORD_MESSAGE); + + chatLog.ChangeMessage(SEASON3B::TYPE_DISCORD_MESSAGE); + CHECK(chatLog.GetNextView() == SEASON3B::TYPE_ALL_MESSAGE); +} + +TEST_CASE("An error is still listed among the messages that caused it [ui][chat]") +{ + CNewUIChatLogWindow chatLog; + chatLog.AddText(L"", L"not online", SEASON3B::TYPE_ERROR_MESSAGE, SEASON3B::TYPE_WHISPER_MESSAGE); + + CHECK(chatLog.GetNumberOfLines(SEASON3B::TYPE_ERROR_MESSAGE) == 1); + CHECK(chatLog.GetNumberOfLines(SEASON3B::TYPE_WHISPER_MESSAGE) == 1); + CHECK(chatLog.GetNumberOfLines(SEASON3B::TYPE_ALL_MESSAGE) == 1); + CHECK(chatLog.GetNumberOfLines(SEASON3B::TYPE_DISCORD_MESSAGE) == 0); +} From d753ce7d3ad1fc49864f9d36672d7bc3c8b78ac1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:03:46 +0000 Subject: [PATCH 2/3] feat(chat): recognise Discord messages of OpenMU's chat bridge OpenMU's chat bridge (MUnique/OpenMU#1161) sends messages written in Discord through the ordinary guild and alliance chat, as the linked character with an '@' prefix that no character name can contain. Such lines are now shown through UI::Chat::External as messages from Discord: "[Discord] " in the guild or alliance colours, in the Discord view, and not whisperable. This makes the C4 display path work against OpenMU today, without waiting for a dedicated packet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012feEciycvL8PGjsezyRFqj --- docs/discord.md | 13 +++++++------ src/source/Network/Server/WSclient.cpp | 12 ++++++++++++ src/source/UI/Chat/ExternalChat.cpp | 15 +++++++++++++++ src/source/UI/Chat/ExternalChat.h | 12 ++++++++++++ tests/ui/test_external_chat.cpp | 8 ++++++++ 5 files changed, 54 insertions(+), 6 deletions(-) diff --git a/docs/discord.md b/docs/discord.md index 4bf38fa319..dd13a7512c 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -50,14 +50,15 @@ The setting applies immediately and is stored in `config.ini`. ## Messages from Discord -> Requires a server whose Discord chat bridge sends them to the client as -> such. Without it, nothing in this section appears; against older servers, -> messages from Discord arrive as ordinary chat with a name prefix. +> Requires a server with a Discord chat bridge, like OpenMU's (its guild and +> alliance chat can be bound to Discord channels). A message written in a Discord channel that the server bridges into the game -appears in the chat of its scope - guild, alliance or world - with that chat's -colours. Its sender is shown as `[Discord] `, with the full Discord name -(up to 32 characters) instead of a character name. +appears in the chat of its scope - guild or alliance - with that chat's +colours. Its sender is shown as `[Discord] `: with OpenMU's bridge the +name is the character the Discord user is linked to (OpenMU sends it as +`@`, which no character can be called). A server that sends the Discord +name itself can show it with up to 32 characters. - Discord users are not characters: right-clicking their line does not start a whisper, and they are not added to your whisper list. diff --git a/src/source/Network/Server/WSclient.cpp b/src/source/Network/Server/WSclient.cpp index 1063691e62..6263a283cd 100644 --- a/src/source/Network/Server/WSclient.cpp +++ b/src/source/Network/Server/WSclient.cpp @@ -1,6 +1,7 @@ #include "stdafx.h" #include "App/Control/ControlTaps.h" #include "Core/Utilities/Log/MuLogger.h" +#include "UI/Chat/ExternalChat.h" #include "UI/Chat/Chat.h" #include #include "UI/Legacy/UIManager.h" @@ -1873,12 +1874,23 @@ void ReceiveChat(const BYTE* ReceiveBuffer) { for (int i = 0; i < messageSize - 2; i++) Text[i] = Text[i + 2]; + if (UI::Chat::External::IsBridgedSender(ID)) + { + // Recorded for the control socket as a Discord line instead. + UI::Chat::External::ShowBridged(UI::Chat::External::Scope::Alliance, ID, Text); + return; + } g_pChatListBox->AddText(ID, Text, SEASON3B::TYPE_UNION_MESSAGE); } else if (Text[0] == L'@') { for (int i = 0; i < messageSize - 1; i++) Text[i] = Text[i + 1]; + if (UI::Chat::External::IsBridgedSender(ID)) + { + UI::Chat::External::ShowBridged(UI::Chat::External::Scope::Guild, ID, Text); + return; + } g_pChatListBox->AddText(ID, Text, SEASON3B::TYPE_GUILD_MESSAGE); } else if (Text[0] == L'$') diff --git a/src/source/UI/Chat/ExternalChat.cpp b/src/source/UI/Chat/ExternalChat.cpp index 5dd85be2ba..1706f33bd2 100644 --- a/src/source/UI/Chat/ExternalChat.cpp +++ b/src/source/UI/Chat/ExternalChat.cpp @@ -56,4 +56,19 @@ void Show(const Message& message) g_pChatListBox->AddExternalText(sender, text, ScopeMessageType(message.scope)); App::Control::Events::RecordChatLine(sender.c_str(), text.c_str(), DiscordChatKind); } + +bool IsBridgedSender(std::wstring_view sender) +{ + return sender.size() > 1 && sender.front() == BridgedSenderPrefix; +} + +void ShowBridged(Scope scope, std::wstring_view sender, std::wstring_view text) +{ + Message message; + message.source = Source::Discord; + message.scope = scope; + message.displayName = std::wstring(sender.substr(1)); + message.text = std::wstring(text); + Show(message); +} } // namespace UI::Chat::External diff --git a/src/source/UI/Chat/ExternalChat.h b/src/source/UI/Chat/ExternalChat.h index 670e508a58..42aa445b77 100644 --- a/src/source/UI/Chat/ExternalChat.h +++ b/src/source/UI/Chat/ExternalChat.h @@ -50,4 +50,16 @@ struct Message // Shows the message in the chat log, in its scope and in the Discord view. void Show(const Message& message); + +// OpenMU's chat bridge (MUnique/OpenMU#1161) sends messages written in Discord +// through the ordinary guild and alliance chat, as the linked character with +// this prefix. Character names can't contain it, so a sender with it is a +// Discord user and not a character. +inline constexpr wchar_t BridgedSenderPrefix = L'@'; + +[[nodiscard]] bool IsBridgedSender(std::wstring_view sender); + +// Shows a guild or alliance chat line of a bridged sender as a message from +// Discord, named after the character without the prefix. +void ShowBridged(Scope scope, std::wstring_view sender, std::wstring_view text); } // namespace UI::Chat::External diff --git a/tests/ui/test_external_chat.cpp b/tests/ui/test_external_chat.cpp index f1c681d873..8f2e9a9e47 100644 --- a/tests/ui/test_external_chat.cpp +++ b/tests/ui/test_external_chat.cpp @@ -32,6 +32,14 @@ TEST_CASE("External chat keeps a Discord name longer than a character's, up to i CHECK(SenderLabel(Source::Discord, longName) == L"[Discord] " + longName.substr(0, MaxDisplayNameLength)); } +TEST_CASE("External chat recognises senders bridged from Discord by their prefix [ui][chat]") +{ + CHECK(IsBridgedSender(L"@Hero")); + CHECK_FALSE(IsBridgedSender(L"Hero")); + CHECK_FALSE(IsBridgedSender(L"@")); + CHECK_FALSE(IsBridgedSender(L"")); +} + TEST_CASE("External chat takes the colours of its scope [ui][chat]") { CHECK(ScopeMessageType(Scope::Guild) == SEASON3B::TYPE_GUILD_MESSAGE); From 0f6e1b2efe7c22729bc874c8507f6dca05a87702 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 12:10:54 +0000 Subject: [PATCH 3/3] docs(chat): the '@' sender check is only a fallback Says next to IsBridgedSender that the prefix is a convention of the server (enforced in character names by MUnique/OpenMU#1174), not something the client can verify, and that ExternalChatMessage is the authoritative signal where the server sends it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012feEciycvL8PGjsezyRFqj --- src/source/UI/Chat/ExternalChat.h | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/source/UI/Chat/ExternalChat.h b/src/source/UI/Chat/ExternalChat.h index 42aa445b77..5a3033ed46 100644 --- a/src/source/UI/Chat/ExternalChat.h +++ b/src/source/UI/Chat/ExternalChat.h @@ -53,8 +53,13 @@ void Show(const Message& message); // OpenMU's chat bridge (MUnique/OpenMU#1161) sends messages written in Discord // through the ordinary guild and alliance chat, as the linked character with -// this prefix. Character names can't contain it, so a sender with it is a -// Discord user and not a character. +// this prefix, which OpenMU doesn't allow in character names. +// +// This is a best-effort fallback for servers which don't send the dedicated +// ExternalChatMessage: the prefix is a convention of the server, not a +// guarantee the client can check - a server which allows it in character +// names would have such characters shown as Discord users. When a server +// sends ExternalChatMessage, that is the authoritative signal. inline constexpr wchar_t BridgedSenderPrefix = L'@'; [[nodiscard]] bool IsBridgedSender(std::wstring_view sender);