mudlet/test/DiscordTest.cpp

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

305 lines
14 KiB
C++
Raw Permalink Normal View History

improve: Give players full control over Discord Rich Presence (#9116) ## Summary Players had no clear way to control what Discord shows about their Mudlet activity. The old checkbox in the connection pane only gated server GMCP data but didn't prevent Discord from showing "Playing Mudlet", and the privacy controls were confusing. This PR replaces all of that with three straightforward modes via radio buttons in Profile Preferences > Chat: - **Show full game details (if supported)** - full game integration with server-provided presence (default) - **Show Mudlet only** - only shows "playing Mudlet", game server is not told about Discord - **Disabled** - Discord shows nothing about Mudlet Players pick the mode that matches their comfort level, and the existing privacy checkboxes (hide detail, hide state, etc.) remain available in Game details mode for finer control. ### What changed - **Three-mode radio buttons** in Profile Preferences > Chat with a two-column layout (modes on the left, privacy controls on the right), replacing the old connection-pane checkbox - **Server-origin tracking** so privacy checkboxes only gate data sent by the game server - Lua API calls always pass through (only Disabled mode blocks Lua entirely) - **Mid-session mode switching** via dynamic GMCP negotiation (Core.Supports.Add/Remove + External.Discord.Hello/Get) - **Deferred RPC init** - Discord RPC now starts when a profile loads, not on app launch - **Username restriction improvements** - takes effect immediately, case-insensitive (Discord usernames are lowercase-only since 2023), shuts down RPC when mismatched - **Shows logged-in Discord user** in preferences next to the restriction field, with a tooltip explaining the desktop app requirement when not connected - **Presence fix** - empty string fields now send nullptr so Discord hides them instead of showing blanks - **Memory leak fix** - presence allocations are now freed in the destructor regardless of RPC state ### Cleanup - Removed obsolete discriminator field (`mRequiredDiscordUserDiscriminator`) - Discord removed discriminators in 2023 - Removed dead code (`getDiscordUserDetails()`, never called) - Restored `Discord_ClearPresence` function pointer for potential future use - Use proper `Host::DiscordOptionFlags` types instead of raw `int` (thanks @SlySven) ### Known quirks - The "Hide timer" checkbox correctly omits timestamps from presence data, but Discord's client starts its own activity timer for any presence without a timestamp - this is Discord client behavior outside our control. - The "Hide large icon" setting clears the image key, but some Discord clients fall back to the application's default icon instead of hiding it entirely. ### Test plan - [ ] Open Profile Preferences > Chat tab - [ ] Switch between the three radio button modes and verify Discord presence updates accordingly - [ ] In Game details mode, toggle privacy checkboxes and verify fields are hidden/shown - [ ] Use Lua API (e.g. `setDiscordDetail("test")`) in Mudlet only mode - should work. Try in Disabled mode - should fail with error - [ ] Set a username restriction and verify presence clears immediately if mismatched - [ ] Run unit tests: `cd build && ./test/DiscordTest` - [ ] Run functional tests: `cd build && ctest -R TDiscordModeTest -V` Closes #6967. Supersedes #7438. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: SlySven <slysven@virginmedia.com>
2026-04-08 13:33:09 +02:00
/***************************************************************************
* Copyright (C) 2025 by Mudlet Makers *
* *
* This program is free software; you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation; either version 2 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program; if not, write to the *
* Free Software Foundation, Inc., *
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
***************************************************************************/
#include <discord.h>
#include <Host.h>
fix: profile close during a map operation, Discord presence truncation, and interrupting ttsSpeak() (#9686) #### Brief overview of PR changes/additions - Closing a profile no longer frees the map out from under a running import, export or download. `TMap` counts the operations that pump `qApp->processEvents()`, and `mudlet::closeHost()` - which one of those pumps is what delivers it - stops the operation and destroys the `Host` once it has unwound, instead of half way through it. - Discord presence fields keep their last character and are only ever cut between characters: each buffer is now the documented limit plus room for its terminator, and a new `utils::copyUtf8String()` walks the cut back to a character boundary. - An interrupting `ttsSpeak()` announces the utterance it starts, and the `Ready` an engine reports for the utterance it cut off no longer drains `ttsQueue()` over the top of the one the script asked for. #### Motivation for adding to Mudlet Each is a filed defect, and each was reproduced before it was fixed. The map one is a use-after-free: ASan reports `heap-use-after-free` inside `TMap::readJsonMapFile()`, freed by `~TMap` <- `~Host` <- `HostManager::deleteHost` <- `mudlet::closeHost` delivered by the import's own `processEvents()`. The Discord one is worse than one field looking wrong: a single over-long non-ASCII field makes the whole `SET_ACTIVITY` payload undecodable, so the entire presence update is discarded - the fake Discord client recorded exactly that. The TTS one silently drops speech: `ttsQueue()` plus an interrupting `ttsSpeak()` speaks the queued line and never speaks the requested one. #### Other info (issues closed, discussion etc) Closes #9520, closes #9634, closes #9659. `MapCloseDuringImportTest` stages the close through `mudlet::slot_closeProfileByName()` and lets the map operation's own pump deliver it; the functional tests build with ASan, so the pre-fix run is a sanitizer report rather than an inference. `TtsInterruptingSpeakTest` hands `ttsStateChanged()` the `Ready` a real engine sends, which Qt's mock engine never does - the mock-visible half is pinned in `Media_spec.lua`, where the two specs that recorded the old behaviour are updated. `Discord_spec.lua` gains four end-to-end specs against `CI/discord-ipc-fixture.py` asserting that the captured frame still decodes as JSON and that a field is cut on a character boundary, and `DiscordTest.cpp` covers the same at unit level. Every new or changed test was confirmed to fail without its fix. Two things deliberately left alone, both older than this PR: `Host::requestClose()` still runs nested inside the map operation's pump (it saves the profile there), and an XML import or a map download has no cancel to poll, so a close waits for it rather than stopping it. **Test case:** Export a large map with `exportJsonMap()` and close the profile's tab while it runs; then `setDiscordDetail(string.rep("ä", 65))` and confirm the presence still updates; then `ttsQueue("queued line") ttsSpeak("first")` followed immediately by `ttsSpeak("second")` and confirm "second" is what gets spoken. Assisted-by: Claude:claude-opus-5
2026-08-07 06:10:42 +02:00
#include <utils.h>
#include <QFile>
improve: Give players full control over Discord Rich Presence (#9116) ## Summary Players had no clear way to control what Discord shows about their Mudlet activity. The old checkbox in the connection pane only gated server GMCP data but didn't prevent Discord from showing "Playing Mudlet", and the privacy controls were confusing. This PR replaces all of that with three straightforward modes via radio buttons in Profile Preferences > Chat: - **Show full game details (if supported)** - full game integration with server-provided presence (default) - **Show Mudlet only** - only shows "playing Mudlet", game server is not told about Discord - **Disabled** - Discord shows nothing about Mudlet Players pick the mode that matches their comfort level, and the existing privacy checkboxes (hide detail, hide state, etc.) remain available in Game details mode for finer control. ### What changed - **Three-mode radio buttons** in Profile Preferences > Chat with a two-column layout (modes on the left, privacy controls on the right), replacing the old connection-pane checkbox - **Server-origin tracking** so privacy checkboxes only gate data sent by the game server - Lua API calls always pass through (only Disabled mode blocks Lua entirely) - **Mid-session mode switching** via dynamic GMCP negotiation (Core.Supports.Add/Remove + External.Discord.Hello/Get) - **Deferred RPC init** - Discord RPC now starts when a profile loads, not on app launch - **Username restriction improvements** - takes effect immediately, case-insensitive (Discord usernames are lowercase-only since 2023), shuts down RPC when mismatched - **Shows logged-in Discord user** in preferences next to the restriction field, with a tooltip explaining the desktop app requirement when not connected - **Presence fix** - empty string fields now send nullptr so Discord hides them instead of showing blanks - **Memory leak fix** - presence allocations are now freed in the destructor regardless of RPC state ### Cleanup - Removed obsolete discriminator field (`mRequiredDiscordUserDiscriminator`) - Discord removed discriminators in 2023 - Removed dead code (`getDiscordUserDetails()`, never called) - Restored `Discord_ClearPresence` function pointer for potential future use - Use proper `Host::DiscordOptionFlags` types instead of raw `int` (thanks @SlySven) ### Known quirks - The "Hide timer" checkbox correctly omits timestamps from presence data, but Discord's client starts its own activity timer for any presence without a timestamp - this is Discord client behavior outside our control. - The "Hide large icon" setting clears the image key, but some Discord clients fall back to the application's default icon instead of hiding it entirely. ### Test plan - [ ] Open Profile Preferences > Chat tab - [ ] Switch between the three radio button modes and verify Discord presence updates accordingly - [ ] In Game details mode, toggle privacy checkboxes and verify fields are hidden/shown - [ ] Use Lua API (e.g. `setDiscordDetail("test")`) in Mudlet only mode - should work. Try in Disabled mode - should fail with error - [ ] Set a username restriction and verify presence clears immediately if mismatched - [ ] Run unit tests: `cd build && ./test/DiscordTest` - [ ] Run functional tests: `cd build && ctest -R TDiscordModeTest -V` Closes #6967. Supersedes #7438. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: SlySven <slysven@virginmedia.com>
2026-04-08 13:33:09 +02:00
#include <QtTest/QtTest>
fix: profile close during a map operation, Discord presence truncation, and interrupting ttsSpeak() (#9686) #### Brief overview of PR changes/additions - Closing a profile no longer frees the map out from under a running import, export or download. `TMap` counts the operations that pump `qApp->processEvents()`, and `mudlet::closeHost()` - which one of those pumps is what delivers it - stops the operation and destroys the `Host` once it has unwound, instead of half way through it. - Discord presence fields keep their last character and are only ever cut between characters: each buffer is now the documented limit plus room for its terminator, and a new `utils::copyUtf8String()` walks the cut back to a character boundary. - An interrupting `ttsSpeak()` announces the utterance it starts, and the `Ready` an engine reports for the utterance it cut off no longer drains `ttsQueue()` over the top of the one the script asked for. #### Motivation for adding to Mudlet Each is a filed defect, and each was reproduced before it was fixed. The map one is a use-after-free: ASan reports `heap-use-after-free` inside `TMap::readJsonMapFile()`, freed by `~TMap` <- `~Host` <- `HostManager::deleteHost` <- `mudlet::closeHost` delivered by the import's own `processEvents()`. The Discord one is worse than one field looking wrong: a single over-long non-ASCII field makes the whole `SET_ACTIVITY` payload undecodable, so the entire presence update is discarded - the fake Discord client recorded exactly that. The TTS one silently drops speech: `ttsQueue()` plus an interrupting `ttsSpeak()` speaks the queued line and never speaks the requested one. #### Other info (issues closed, discussion etc) Closes #9520, closes #9634, closes #9659. `MapCloseDuringImportTest` stages the close through `mudlet::slot_closeProfileByName()` and lets the map operation's own pump deliver it; the functional tests build with ASan, so the pre-fix run is a sanitizer report rather than an inference. `TtsInterruptingSpeakTest` hands `ttsStateChanged()` the `Ready` a real engine sends, which Qt's mock engine never does - the mock-visible half is pinned in `Media_spec.lua`, where the two specs that recorded the old behaviour are updated. `Discord_spec.lua` gains four end-to-end specs against `CI/discord-ipc-fixture.py` asserting that the captured frame still decodes as JSON and that a field is cut on a character boundary, and `DiscordTest.cpp` covers the same at unit level. Every new or changed test was confirmed to fail without its fix. Two things deliberately left alone, both older than this PR: `Host::requestClose()` still runs nested inside the map operation's pump (it saves the profile there), and an XML import or a map download has no cancel to poll, so a close waits for it rather than stopping it. **Test case:** Export a large map with `exportJsonMap()` and close the profile's tab while it runs; then `setDiscordDetail(string.rep("ä", 65))` and confirm the presence still updates; then `ttsQueue("queued line") ttsSpeak("first")` followed immediately by `ttsSpeak("second")` and confirm "second" is what gets spoken. Assisted-by: Claude:claude-opus-5
2026-08-07 06:10:42 +02:00
class DiscordTest : public QObject
{
improve: Give players full control over Discord Rich Presence (#9116) ## Summary Players had no clear way to control what Discord shows about their Mudlet activity. The old checkbox in the connection pane only gated server GMCP data but didn't prevent Discord from showing "Playing Mudlet", and the privacy controls were confusing. This PR replaces all of that with three straightforward modes via radio buttons in Profile Preferences > Chat: - **Show full game details (if supported)** - full game integration with server-provided presence (default) - **Show Mudlet only** - only shows "playing Mudlet", game server is not told about Discord - **Disabled** - Discord shows nothing about Mudlet Players pick the mode that matches their comfort level, and the existing privacy checkboxes (hide detail, hide state, etc.) remain available in Game details mode for finer control. ### What changed - **Three-mode radio buttons** in Profile Preferences > Chat with a two-column layout (modes on the left, privacy controls on the right), replacing the old connection-pane checkbox - **Server-origin tracking** so privacy checkboxes only gate data sent by the game server - Lua API calls always pass through (only Disabled mode blocks Lua entirely) - **Mid-session mode switching** via dynamic GMCP negotiation (Core.Supports.Add/Remove + External.Discord.Hello/Get) - **Deferred RPC init** - Discord RPC now starts when a profile loads, not on app launch - **Username restriction improvements** - takes effect immediately, case-insensitive (Discord usernames are lowercase-only since 2023), shuts down RPC when mismatched - **Shows logged-in Discord user** in preferences next to the restriction field, with a tooltip explaining the desktop app requirement when not connected - **Presence fix** - empty string fields now send nullptr so Discord hides them instead of showing blanks - **Memory leak fix** - presence allocations are now freed in the destructor regardless of RPC state ### Cleanup - Removed obsolete discriminator field (`mRequiredDiscordUserDiscriminator`) - Discord removed discriminators in 2023 - Removed dead code (`getDiscordUserDetails()`, never called) - Restored `Discord_ClearPresence` function pointer for potential future use - Use proper `Host::DiscordOptionFlags` types instead of raw `int` (thanks @SlySven) ### Known quirks - The "Hide timer" checkbox correctly omits timestamps from presence data, but Discord's client starts its own activity timer for any presence without a timestamp - this is Discord client behavior outside our control. - The "Hide large icon" setting clears the image key, but some Discord clients fall back to the application's default icon instead of hiding it entirely. ### Test plan - [ ] Open Profile Preferences > Chat tab - [ ] Switch between the three radio button modes and verify Discord presence updates accordingly - [ ] In Game details mode, toggle privacy checkboxes and verify fields are hidden/shown - [ ] Use Lua API (e.g. `setDiscordDetail("test")`) in Mudlet only mode - should work. Try in Disabled mode - should fail with error - [ ] Set a username restriction and verify presence clears immediately if mismatched - [ ] Run unit tests: `cd build && ./test/DiscordTest` - [ ] Run functional tests: `cd build && ctest -R TDiscordModeTest -V` Closes #6967. Supersedes #7438. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: SlySven <slysven@virginmedia.com>
2026-04-08 13:33:09 +02:00
Q_OBJECT
private slots:
fix: profile close during a map operation, Discord presence truncation, and interrupting ttsSpeak() (#9686) #### Brief overview of PR changes/additions - Closing a profile no longer frees the map out from under a running import, export or download. `TMap` counts the operations that pump `qApp->processEvents()`, and `mudlet::closeHost()` - which one of those pumps is what delivers it - stops the operation and destroys the `Host` once it has unwound, instead of half way through it. - Discord presence fields keep their last character and are only ever cut between characters: each buffer is now the documented limit plus room for its terminator, and a new `utils::copyUtf8String()` walks the cut back to a character boundary. - An interrupting `ttsSpeak()` announces the utterance it starts, and the `Ready` an engine reports for the utterance it cut off no longer drains `ttsQueue()` over the top of the one the script asked for. #### Motivation for adding to Mudlet Each is a filed defect, and each was reproduced before it was fixed. The map one is a use-after-free: ASan reports `heap-use-after-free` inside `TMap::readJsonMapFile()`, freed by `~TMap` <- `~Host` <- `HostManager::deleteHost` <- `mudlet::closeHost` delivered by the import's own `processEvents()`. The Discord one is worse than one field looking wrong: a single over-long non-ASCII field makes the whole `SET_ACTIVITY` payload undecodable, so the entire presence update is discarded - the fake Discord client recorded exactly that. The TTS one silently drops speech: `ttsQueue()` plus an interrupting `ttsSpeak()` speaks the queued line and never speaks the requested one. #### Other info (issues closed, discussion etc) Closes #9520, closes #9634, closes #9659. `MapCloseDuringImportTest` stages the close through `mudlet::slot_closeProfileByName()` and lets the map operation's own pump deliver it; the functional tests build with ASan, so the pre-fix run is a sanitizer report rather than an inference. `TtsInterruptingSpeakTest` hands `ttsStateChanged()` the `Ready` a real engine sends, which Qt's mock engine never does - the mock-visible half is pinned in `Media_spec.lua`, where the two specs that recorded the old behaviour are updated. `Discord_spec.lua` gains four end-to-end specs against `CI/discord-ipc-fixture.py` asserting that the captured frame still decodes as JSON and that a field is cut on a character boundary, and `DiscordTest.cpp` covers the same at unit level. Every new or changed test was confirmed to fail without its fix. Two things deliberately left alone, both older than this PR: `Host::requestClose()` still runs nested inside the map operation's pump (it saves the profile there), and an XML import or a map download has no cancel to poll, so a close waits for it rather than stopping it. **Test case:** Export a large map with `exportJsonMap()` and close the profile's tab while it runs; then `setDiscordDetail(string.rep("ä", 65))` and confirm the presence still updates; then `ttsQueue("queued line") ttsSpeak("first")` followed immediately by `ttsSpeak("second")` and confirm "second" is what gets spoken. Assisted-by: Claude:claude-opus-5
2026-08-07 06:10:42 +02:00
void initTestCase() {}
improve: Give players full control over Discord Rich Presence (#9116) ## Summary Players had no clear way to control what Discord shows about their Mudlet activity. The old checkbox in the connection pane only gated server GMCP data but didn't prevent Discord from showing "Playing Mudlet", and the privacy controls were confusing. This PR replaces all of that with three straightforward modes via radio buttons in Profile Preferences > Chat: - **Show full game details (if supported)** - full game integration with server-provided presence (default) - **Show Mudlet only** - only shows "playing Mudlet", game server is not told about Discord - **Disabled** - Discord shows nothing about Mudlet Players pick the mode that matches their comfort level, and the existing privacy checkboxes (hide detail, hide state, etc.) remain available in Game details mode for finer control. ### What changed - **Three-mode radio buttons** in Profile Preferences > Chat with a two-column layout (modes on the left, privacy controls on the right), replacing the old connection-pane checkbox - **Server-origin tracking** so privacy checkboxes only gate data sent by the game server - Lua API calls always pass through (only Disabled mode blocks Lua entirely) - **Mid-session mode switching** via dynamic GMCP negotiation (Core.Supports.Add/Remove + External.Discord.Hello/Get) - **Deferred RPC init** - Discord RPC now starts when a profile loads, not on app launch - **Username restriction improvements** - takes effect immediately, case-insensitive (Discord usernames are lowercase-only since 2023), shuts down RPC when mismatched - **Shows logged-in Discord user** in preferences next to the restriction field, with a tooltip explaining the desktop app requirement when not connected - **Presence fix** - empty string fields now send nullptr so Discord hides them instead of showing blanks - **Memory leak fix** - presence allocations are now freed in the destructor regardless of RPC state ### Cleanup - Removed obsolete discriminator field (`mRequiredDiscordUserDiscriminator`) - Discord removed discriminators in 2023 - Removed dead code (`getDiscordUserDetails()`, never called) - Restored `Discord_ClearPresence` function pointer for potential future use - Use proper `Host::DiscordOptionFlags` types instead of raw `int` (thanks @SlySven) ### Known quirks - The "Hide timer" checkbox correctly omits timestamps from presence data, but Discord's client starts its own activity timer for any presence without a timestamp - this is Discord client behavior outside our control. - The "Hide large icon" setting clears the image key, but some Discord clients fall back to the application's default icon instead of hiding it entirely. ### Test plan - [ ] Open Profile Preferences > Chat tab - [ ] Switch between the three radio button modes and verify Discord presence updates accordingly - [ ] In Game details mode, toggle privacy checkboxes and verify fields are hidden/shown - [ ] Use Lua API (e.g. `setDiscordDetail("test")`) in Mudlet only mode - should work. Try in Disabled mode - should fail with error - [ ] Set a username restriction and verify presence clears immediately if mismatched - [ ] Run unit tests: `cd build && ./test/DiscordTest` - [ ] Run functional tests: `cd build && ctest -R TDiscordModeTest -V` Closes #6967. Supersedes #7438. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: SlySven <slysven@virginmedia.com>
2026-04-08 13:33:09 +02:00
// Test that convert() returns nullptr for empty string fields
void testConvertNullIfEmpty()
{
localDiscordPresence presence;
// All strings default to empty char arrays
DiscordRichPresence converted = presence.convert();
QVERIFY2(converted.state == nullptr, "Empty state should convert to nullptr");
QVERIFY2(converted.details == nullptr, "Empty details should convert to nullptr");
QVERIFY2(converted.largeImageKey == nullptr, "Empty largeImageKey should convert to nullptr");
QVERIFY2(converted.largeImageText == nullptr, "Empty largeImageText should convert to nullptr");
QVERIFY2(converted.smallImageKey == nullptr, "Empty smallImageKey should convert to nullptr");
QVERIFY2(converted.smallImageText == nullptr, "Empty smallImageText should convert to nullptr");
QVERIFY2(converted.partyId == nullptr, "Empty partyId should convert to nullptr");
QVERIFY2(converted.matchSecret == nullptr, "Empty matchSecret should convert to nullptr");
QVERIFY2(converted.joinSecret == nullptr, "Empty joinSecret should convert to nullptr");
QVERIFY2(converted.spectateSecret == nullptr, "Empty spectateSecret should convert to nullptr");
}
// Test that convert() returns valid pointers for non-empty strings
void testConvertNonEmpty()
{
localDiscordPresence presence;
presence.setDetailText(qsl("test detail"));
presence.setStateText(qsl("test state"));
presence.setLargeImageKey(qsl("icon-key"));
DiscordRichPresence converted = presence.convert();
QVERIFY(converted.details != nullptr);
QCOMPARE(QString::fromUtf8(converted.details), qsl("test detail"));
QVERIFY(converted.state != nullptr);
QCOMPARE(QString::fromUtf8(converted.state), qsl("test state"));
QVERIFY(converted.largeImageKey != nullptr);
QCOMPARE(QString::fromUtf8(converted.largeImageKey), qsl("icon-key"));
// Fields we didn't set should still be nullptr
QVERIFY(converted.smallImageKey == nullptr);
}
// Test that timestamps are correctly set and cleared
void testConvertTimestamps()
{
localDiscordPresence presence;
// Default timestamps should be 0
DiscordRichPresence converted = presence.convert();
QCOMPARE(converted.startTimestamp, static_cast<int64_t>(0));
QCOMPARE(converted.endTimestamp, static_cast<int64_t>(0));
// Set a start timestamp
presence.setStartTimeStamp(1234567890);
converted = presence.convert();
QCOMPARE(converted.startTimestamp, static_cast<int64_t>(1234567890));
QCOMPARE(converted.endTimestamp, static_cast<int64_t>(0));
// Clear it back to 0
presence.setStartTimeStamp(0);
converted = presence.convert();
QCOMPARE(converted.startTimestamp, static_cast<int64_t>(0));
}
// Test that party size/max are correctly set
void testConvertParty()
{
localDiscordPresence presence;
DiscordRichPresence converted = presence.convert();
QCOMPARE(converted.partySize, 0);
QCOMPARE(converted.partyMax, 0);
presence.setPartySize(3);
presence.setPartyMax(10);
converted = presence.convert();
QCOMPARE(converted.partySize, 3);
QCOMPARE(converted.partyMax, 10);
// Clear party
presence.setPartySize(0);
presence.setPartyMax(0);
converted = presence.convert();
QCOMPARE(converted.partySize, 0);
QCOMPARE(converted.partyMax, 0);
}
// Test that setDetailText with empty string produces nullptr in convert
void testEmptyDetailProducesNull()
{
localDiscordPresence presence;
presence.setDetailText(QString());
DiscordRichPresence converted = presence.convert();
// Empty string should convert to nullptr via nullIfEmpty
QVERIFY2(converted.details == nullptr, "Empty detail text should convert to nullptr");
}
// Test DiscordMode enum values match expected integers for serialization
void testDiscordModeValues()
{
QCOMPARE(static_cast<int>(Host::DiscordDisabled), 0);
QCOMPARE(static_cast<int>(Host::DiscordShowMudletOnly), 1);
QCOMPARE(static_cast<int>(Host::DiscordShowGameDetails), 2);
}
// Test DiscordOptionFlag values for server-origin tracking compatibility
void testDiscordOptionFlagValues()
{
// Verify the sub-mask covers all individual field flags
QVERIFY(Host::DiscordSetSubMask & Host::DiscordSetDetail);
QVERIFY(Host::DiscordSetSubMask & Host::DiscordSetState);
QVERIFY(Host::DiscordSetSubMask & Host::DiscordSetLargeIcon);
QVERIFY(Host::DiscordSetSubMask & Host::DiscordSetLargeIconText);
QVERIFY(Host::DiscordSetSubMask & Host::DiscordSetSmallIcon);
QVERIFY(Host::DiscordSetSubMask & Host::DiscordSetSmallIconText);
QVERIFY(Host::DiscordSetSubMask & Host::DiscordSetPartyInfo);
QVERIFY(Host::DiscordSetSubMask & Host::DiscordSetTimeInfo);
// DiscordLuaAccessEnabled should NOT be in the sub-mask
QVERIFY(!(Host::DiscordSetSubMask & Host::DiscordLuaAccessEnabled));
}
// Test that string truncation works for fields exceeding buffer size
void testStringTruncation()
{
localDiscordPresence presence;
fix: profile close during a map operation, Discord presence truncation, and interrupting ttsSpeak() (#9686) #### Brief overview of PR changes/additions - Closing a profile no longer frees the map out from under a running import, export or download. `TMap` counts the operations that pump `qApp->processEvents()`, and `mudlet::closeHost()` - which one of those pumps is what delivers it - stops the operation and destroys the `Host` once it has unwound, instead of half way through it. - Discord presence fields keep their last character and are only ever cut between characters: each buffer is now the documented limit plus room for its terminator, and a new `utils::copyUtf8String()` walks the cut back to a character boundary. - An interrupting `ttsSpeak()` announces the utterance it starts, and the `Ready` an engine reports for the utterance it cut off no longer drains `ttsQueue()` over the top of the one the script asked for. #### Motivation for adding to Mudlet Each is a filed defect, and each was reproduced before it was fixed. The map one is a use-after-free: ASan reports `heap-use-after-free` inside `TMap::readJsonMapFile()`, freed by `~TMap` <- `~Host` <- `HostManager::deleteHost` <- `mudlet::closeHost` delivered by the import's own `processEvents()`. The Discord one is worse than one field looking wrong: a single over-long non-ASCII field makes the whole `SET_ACTIVITY` payload undecodable, so the entire presence update is discarded - the fake Discord client recorded exactly that. The TTS one silently drops speech: `ttsQueue()` plus an interrupting `ttsSpeak()` speaks the queued line and never speaks the requested one. #### Other info (issues closed, discussion etc) Closes #9520, closes #9634, closes #9659. `MapCloseDuringImportTest` stages the close through `mudlet::slot_closeProfileByName()` and lets the map operation's own pump deliver it; the functional tests build with ASan, so the pre-fix run is a sanitizer report rather than an inference. `TtsInterruptingSpeakTest` hands `ttsStateChanged()` the `Ready` a real engine sends, which Qt's mock engine never does - the mock-visible half is pinned in `Media_spec.lua`, where the two specs that recorded the old behaviour are updated. `Discord_spec.lua` gains four end-to-end specs against `CI/discord-ipc-fixture.py` asserting that the captured frame still decodes as JSON and that a field is cut on a character boundary, and `DiscordTest.cpp` covers the same at unit level. Every new or changed test was confirmed to fail without its fix. Two things deliberately left alone, both older than this PR: `Host::requestClose()` still runs nested inside the map operation's pump (it saves the profile there), and an XML import or a map download has no cancel to poll, so a close waits for it rather than stopping it. **Test case:** Export a large map with `exportJsonMap()` and close the profile's tab while it runs; then `setDiscordDetail(string.rep("ä", 65))` and confirm the presence still updates; then `ttsQueue("queued line") ttsSpeak("first")` followed immediately by `ttsSpeak("second")` and confirm "second" is what gets spoken. Assisted-by: Claude:claude-opus-5
2026-08-07 06:10:42 +02:00
// Discord documents details as holding 128 bytes, so a longer string is
// cut down to exactly that - the buffer allows for its own terminator
// rather than spending one of those 128 bytes on it (#9634).
improve: Give players full control over Discord Rich Presence (#9116) ## Summary Players had no clear way to control what Discord shows about their Mudlet activity. The old checkbox in the connection pane only gated server GMCP data but didn't prevent Discord from showing "Playing Mudlet", and the privacy controls were confusing. This PR replaces all of that with three straightforward modes via radio buttons in Profile Preferences > Chat: - **Show full game details (if supported)** - full game integration with server-provided presence (default) - **Show Mudlet only** - only shows "playing Mudlet", game server is not told about Discord - **Disabled** - Discord shows nothing about Mudlet Players pick the mode that matches their comfort level, and the existing privacy checkboxes (hide detail, hide state, etc.) remain available in Game details mode for finer control. ### What changed - **Three-mode radio buttons** in Profile Preferences > Chat with a two-column layout (modes on the left, privacy controls on the right), replacing the old connection-pane checkbox - **Server-origin tracking** so privacy checkboxes only gate data sent by the game server - Lua API calls always pass through (only Disabled mode blocks Lua entirely) - **Mid-session mode switching** via dynamic GMCP negotiation (Core.Supports.Add/Remove + External.Discord.Hello/Get) - **Deferred RPC init** - Discord RPC now starts when a profile loads, not on app launch - **Username restriction improvements** - takes effect immediately, case-insensitive (Discord usernames are lowercase-only since 2023), shuts down RPC when mismatched - **Shows logged-in Discord user** in preferences next to the restriction field, with a tooltip explaining the desktop app requirement when not connected - **Presence fix** - empty string fields now send nullptr so Discord hides them instead of showing blanks - **Memory leak fix** - presence allocations are now freed in the destructor regardless of RPC state ### Cleanup - Removed obsolete discriminator field (`mRequiredDiscordUserDiscriminator`) - Discord removed discriminators in 2023 - Removed dead code (`getDiscordUserDetails()`, never called) - Restored `Discord_ClearPresence` function pointer for potential future use - Use proper `Host::DiscordOptionFlags` types instead of raw `int` (thanks @SlySven) ### Known quirks - The "Hide timer" checkbox correctly omits timestamps from presence data, but Discord's client starts its own activity timer for any presence without a timestamp - this is Discord client behavior outside our control. - The "Hide large icon" setting clears the image key, but some Discord clients fall back to the application's default icon instead of hiding it entirely. ### Test plan - [ ] Open Profile Preferences > Chat tab - [ ] Switch between the three radio button modes and verify Discord presence updates accordingly - [ ] In Game details mode, toggle privacy checkboxes and verify fields are hidden/shown - [ ] Use Lua API (e.g. `setDiscordDetail("test")`) in Mudlet only mode - should work. Try in Disabled mode - should fail with error - [ ] Set a username restriction and verify presence clears immediately if mismatched - [ ] Run unit tests: `cd build && ./test/DiscordTest` - [ ] Run functional tests: `cd build && ctest -R TDiscordModeTest -V` Closes #6967. Supersedes #7438. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: SlySven <slysven@virginmedia.com>
2026-04-08 13:33:09 +02:00
QString longString(200, QChar('A'));
presence.setDetailText(longString);
DiscordRichPresence converted = presence.convert();
QVERIFY(converted.details != nullptr);
fix: profile close during a map operation, Discord presence truncation, and interrupting ttsSpeak() (#9686) #### Brief overview of PR changes/additions - Closing a profile no longer frees the map out from under a running import, export or download. `TMap` counts the operations that pump `qApp->processEvents()`, and `mudlet::closeHost()` - which one of those pumps is what delivers it - stops the operation and destroys the `Host` once it has unwound, instead of half way through it. - Discord presence fields keep their last character and are only ever cut between characters: each buffer is now the documented limit plus room for its terminator, and a new `utils::copyUtf8String()` walks the cut back to a character boundary. - An interrupting `ttsSpeak()` announces the utterance it starts, and the `Ready` an engine reports for the utterance it cut off no longer drains `ttsQueue()` over the top of the one the script asked for. #### Motivation for adding to Mudlet Each is a filed defect, and each was reproduced before it was fixed. The map one is a use-after-free: ASan reports `heap-use-after-free` inside `TMap::readJsonMapFile()`, freed by `~TMap` <- `~Host` <- `HostManager::deleteHost` <- `mudlet::closeHost` delivered by the import's own `processEvents()`. The Discord one is worse than one field looking wrong: a single over-long non-ASCII field makes the whole `SET_ACTIVITY` payload undecodable, so the entire presence update is discarded - the fake Discord client recorded exactly that. The TTS one silently drops speech: `ttsQueue()` plus an interrupting `ttsSpeak()` speaks the queued line and never speaks the requested one. #### Other info (issues closed, discussion etc) Closes #9520, closes #9634, closes #9659. `MapCloseDuringImportTest` stages the close through `mudlet::slot_closeProfileByName()` and lets the map operation's own pump deliver it; the functional tests build with ASan, so the pre-fix run is a sanitizer report rather than an inference. `TtsInterruptingSpeakTest` hands `ttsStateChanged()` the `Ready` a real engine sends, which Qt's mock engine never does - the mock-visible half is pinned in `Media_spec.lua`, where the two specs that recorded the old behaviour are updated. `Discord_spec.lua` gains four end-to-end specs against `CI/discord-ipc-fixture.py` asserting that the captured frame still decodes as JSON and that a field is cut on a character boundary, and `DiscordTest.cpp` covers the same at unit level. Every new or changed test was confirmed to fail without its fix. Two things deliberately left alone, both older than this PR: `Host::requestClose()` still runs nested inside the map operation's pump (it saves the profile there), and an XML import or a map download has no cancel to poll, so a close waits for it rather than stopping it. **Test case:** Export a large map with `exportJsonMap()` and close the profile's tab while it runs; then `setDiscordDetail(string.rep("ä", 65))` and confirm the presence still updates; then `ttsQueue("queued line") ttsSpeak("first")` followed immediately by `ttsSpeak("second")` and confirm "second" is what gets spoken. Assisted-by: Claude:claude-opus-5
2026-08-07 06:10:42 +02:00
QCOMPARE(strlen(converted.details), size_t{128});
}
// A field of exactly the documented length has to arrive whole: an asset key
// that loses its last character resolves to no icon at all (#9634).
void testFullLengthFieldsSurviveWhole()
{
localDiscordPresence presence;
presence.setLargeImageKey(QString(32, QChar('a')));
presence.setStateText(QString(128, QChar('s')));
DiscordRichPresence converted = presence.convert();
QCOMPARE(strlen(converted.largeImageKey), size_t{32});
QCOMPARE(strlen(converted.state), size_t{128});
}
// Truncation has to fall between characters. A field cut through the middle
// of a multi-byte one is no longer valid UTF-8, and Discord discards the
// whole presence frame carrying it rather than just that field (#9634).
void testTruncationKeepsUtf8Intact()
{
localDiscordPresence presence;
// 65 two-byte characters: 130 bytes, so the cut has to fall inside the
// 65th and take all of it.
presence.setDetailText(QString(65, QChar(0x00E9)));
// 17 of the same in a 32 byte field, which holds 16 of them.
presence.setLargeImageKey(QString(17, QChar(0x00E9)));
DiscordRichPresence converted = presence.convert();
QCOMPARE(QByteArray(converted.details), QString(64, QChar(0x00E9)).toUtf8());
QCOMPARE(QByteArray(converted.largeImageKey), QString(16, QChar(0x00E9)).toUtf8());
// A three-byte character has two ways to be cut in half, so check the
// other one too: 43 of them are 129 bytes.
presence.setStateText(QString(43, QChar(0x4F60)));
converted = presence.convert();
QCOMPARE(QByteArray(converted.state), QString(42, QChar(0x4F60)).toUtf8());
// And an emoji, the four-byte case, where the walk-back has to step
// over three continuation bytes: 33 of them are 132 bytes.
const char32_t grinningFace = 0x1F600;
const QString emoji = QString::fromUcs4(&grinningFace, 1);
presence.setDetailText(emoji.repeated(33));
converted = presence.convert();
QCOMPARE(QByteArray(converted.details), emoji.repeated(32).toUtf8());
}
// The truncation itself, at boundaries the fixed-size presence fields
// cannot reach.
void testCopyUtf8StringEdgeCases()
{
char buffer[8];
// Nothing to copy, and a destination too small even to terminate:
QCOMPARE(utils::copyUtf8String(buffer, sizeof(buffer), "", 0), size_t{0});
QCOMPARE(utils::copyUtf8String(buffer, 0, "abc", 3), size_t{0});
// Exactly filling the usable space is not a truncation, so there is
// nothing to walk back from:
QCOMPARE(utils::copyUtf8String(buffer, sizeof(buffer), "abcdefg", 7), size_t{7});
QCOMPARE(QByteArray(buffer), QByteArray("abcdefg"));
// One byte too many, cut between characters:
QCOMPARE(utils::copyUtf8String(buffer, sizeof(buffer), "abcdefgh", 8), size_t{7});
// Input that is nothing but continuation bytes cannot be cut anywhere
// valid, so an empty field is what comes out - never a broken sequence.
const char continuationBytes[] = "\x80\x80\x80\x80\x80\x80\x80\x80\x80";
QCOMPARE(utils::copyUtf8String(buffer, sizeof(buffer), continuationBytes, 9), size_t{0});
QCOMPARE(QByteArray(buffer), QByteArray());
improve: Give players full control over Discord Rich Presence (#9116) ## Summary Players had no clear way to control what Discord shows about their Mudlet activity. The old checkbox in the connection pane only gated server GMCP data but didn't prevent Discord from showing "Playing Mudlet", and the privacy controls were confusing. This PR replaces all of that with three straightforward modes via radio buttons in Profile Preferences > Chat: - **Show full game details (if supported)** - full game integration with server-provided presence (default) - **Show Mudlet only** - only shows "playing Mudlet", game server is not told about Discord - **Disabled** - Discord shows nothing about Mudlet Players pick the mode that matches their comfort level, and the existing privacy checkboxes (hide detail, hide state, etc.) remain available in Game details mode for finer control. ### What changed - **Three-mode radio buttons** in Profile Preferences > Chat with a two-column layout (modes on the left, privacy controls on the right), replacing the old connection-pane checkbox - **Server-origin tracking** so privacy checkboxes only gate data sent by the game server - Lua API calls always pass through (only Disabled mode blocks Lua entirely) - **Mid-session mode switching** via dynamic GMCP negotiation (Core.Supports.Add/Remove + External.Discord.Hello/Get) - **Deferred RPC init** - Discord RPC now starts when a profile loads, not on app launch - **Username restriction improvements** - takes effect immediately, case-insensitive (Discord usernames are lowercase-only since 2023), shuts down RPC when mismatched - **Shows logged-in Discord user** in preferences next to the restriction field, with a tooltip explaining the desktop app requirement when not connected - **Presence fix** - empty string fields now send nullptr so Discord hides them instead of showing blanks - **Memory leak fix** - presence allocations are now freed in the destructor regardless of RPC state ### Cleanup - Removed obsolete discriminator field (`mRequiredDiscordUserDiscriminator`) - Discord removed discriminators in 2023 - Removed dead code (`getDiscordUserDetails()`, never called) - Restored `Discord_ClearPresence` function pointer for potential future use - Use proper `Host::DiscordOptionFlags` types instead of raw `int` (thanks @SlySven) ### Known quirks - The "Hide timer" checkbox correctly omits timestamps from presence data, but Discord's client starts its own activity timer for any presence without a timestamp - this is Discord client behavior outside our control. - The "Hide large icon" setting clears the image key, but some Discord clients fall back to the application's default icon instead of hiding it entirely. ### Test plan - [ ] Open Profile Preferences > Chat tab - [ ] Switch between the three radio button modes and verify Discord presence updates accordingly - [ ] In Game details mode, toggle privacy checkboxes and verify fields are hidden/shown - [ ] Use Lua API (e.g. `setDiscordDetail("test")`) in Mudlet only mode - should work. Try in Disabled mode - should fail with error - [ ] Set a username restriction and verify presence clears immediately if mismatched - [ ] Run unit tests: `cd build && ./test/DiscordTest` - [ ] Run functional tests: `cd build && ctest -R TDiscordModeTest -V` Closes #6967. Supersedes #7438. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: SlySven <slysven@virginmedia.com>
2026-04-08 13:33:09 +02:00
}
// Test that Discord username comparison is case-insensitive.
// This mirrors the logic in Host::discordUserIdMatch without
// constructing a Host (which has heavy dependencies).
void testUserNameComparisonCaseInsensitive()
{
// The comparison logic from Host::discordUserIdMatch:
// if userName and required are both non-empty, compare toLower()
auto matchesRequired = [](const QString& loggedInUser, const QString& requiredUser) -> bool {
if (!loggedInUser.isEmpty() && !requiredUser.isEmpty() && loggedInUser.toLower() != requiredUser.toLower()) {
return false;
}
return true;
};
// No restriction - should always match
QVERIFY(matchesRequired(qsl("anyuser"), QString()));
QVERIFY(matchesRequired(QString(), QString()));
// Exact lowercase match
QVERIFY(matchesRequired(qsl("morquin"), qsl("morquin")));
// Mixed case should still match
QVERIFY(matchesRequired(qsl("Morquin"), qsl("morquin")));
QVERIFY(matchesRequired(qsl("MORQUIN"), qsl("morquin")));
QVERIFY(matchesRequired(qsl("morquin"), qsl("Morquin")));
// Wrong user should not match
QVERIFY(!matchesRequired(qsl("someone_else"), qsl("morquin")));
// Empty logged-in user (not connected yet) should match
QVERIFY(matchesRequired(QString(), qsl("morquin")));
}
// The Lua API permission gating contract: mutators (setDiscord* and
// resetDiscordData) must require write access - discordApiEnabled(L, true)
// denies them while the API is read-only because the logged-in Discord
// user differs from the profile's restriction - while getters only need
// read access. Exercising the real functions needs a live profile (see
// TDiscordModeTest); this scans the source instead, like
// CMakeListsConsistencyTest does, so a swapped flag fails here too.
void testLuaApiGatingContract()
{
QFile source(qsl(MUDLET_SRC_DIR "/TLuaInterpreterDiscord.cpp"));
QVERIFY2(source.open(QIODevice::ReadOnly | QIODevice::Text), "cannot open TLuaInterpreterDiscord.cpp");
const QString text = QString::fromUtf8(source.readAll());
const QStringList chunks = text.split(qsl("int TLuaInterpreter::"));
int checked = 0;
for (int i = 1; i < chunks.size(); ++i) {
const QString& chunk = chunks.at(i);
const QString name = chunk.left(chunk.indexOf(QLatin1Char('(')));
const bool readGate = chunk.contains(qsl("discordApiEnabled(L)"));
const bool writeGate = chunk.contains(qsl("discordApiEnabled(L, true)"));
if (name == qsl("setDiscordGameUrl")) {
// Intentionally ungated: the invite URL is not part of rich
// presence (see the comment in the function itself)
QVERIFY2(!readGate && !writeGate, qPrintable(qsl("%1 is documented as exempt from the Discord API gate").arg(name)));
++checked;
} else if (name.startsWith(qsl("setDiscord")) || name == qsl("resetDiscordData")) {
QVERIFY2(writeGate && !readGate, qPrintable(qsl("%1 mutates Discord data so it must call discordApiEnabled(L, true)").arg(name)));
++checked;
} else if (name.startsWith(qsl("getDiscord")) || name == qsl("usingMudletsDiscordID")) {
QVERIFY2(readGate && !writeGate, qPrintable(qsl("%1 only reads Discord data so it must call discordApiEnabled(L)").arg(name)));
++checked;
}
}
QVERIFY2(checked >= 22, qPrintable(qsl("only categorised %1 Discord Lua functions - has the source moved?").arg(checked)));
}
fix: profile close during a map operation, Discord presence truncation, and interrupting ttsSpeak() (#9686) #### Brief overview of PR changes/additions - Closing a profile no longer frees the map out from under a running import, export or download. `TMap` counts the operations that pump `qApp->processEvents()`, and `mudlet::closeHost()` - which one of those pumps is what delivers it - stops the operation and destroys the `Host` once it has unwound, instead of half way through it. - Discord presence fields keep their last character and are only ever cut between characters: each buffer is now the documented limit plus room for its terminator, and a new `utils::copyUtf8String()` walks the cut back to a character boundary. - An interrupting `ttsSpeak()` announces the utterance it starts, and the `Ready` an engine reports for the utterance it cut off no longer drains `ttsQueue()` over the top of the one the script asked for. #### Motivation for adding to Mudlet Each is a filed defect, and each was reproduced before it was fixed. The map one is a use-after-free: ASan reports `heap-use-after-free` inside `TMap::readJsonMapFile()`, freed by `~TMap` <- `~Host` <- `HostManager::deleteHost` <- `mudlet::closeHost` delivered by the import's own `processEvents()`. The Discord one is worse than one field looking wrong: a single over-long non-ASCII field makes the whole `SET_ACTIVITY` payload undecodable, so the entire presence update is discarded - the fake Discord client recorded exactly that. The TTS one silently drops speech: `ttsQueue()` plus an interrupting `ttsSpeak()` speaks the queued line and never speaks the requested one. #### Other info (issues closed, discussion etc) Closes #9520, closes #9634, closes #9659. `MapCloseDuringImportTest` stages the close through `mudlet::slot_closeProfileByName()` and lets the map operation's own pump deliver it; the functional tests build with ASan, so the pre-fix run is a sanitizer report rather than an inference. `TtsInterruptingSpeakTest` hands `ttsStateChanged()` the `Ready` a real engine sends, which Qt's mock engine never does - the mock-visible half is pinned in `Media_spec.lua`, where the two specs that recorded the old behaviour are updated. `Discord_spec.lua` gains four end-to-end specs against `CI/discord-ipc-fixture.py` asserting that the captured frame still decodes as JSON and that a field is cut on a character boundary, and `DiscordTest.cpp` covers the same at unit level. Every new or changed test was confirmed to fail without its fix. Two things deliberately left alone, both older than this PR: `Host::requestClose()` still runs nested inside the map operation's pump (it saves the profile there), and an XML import or a map download has no cancel to poll, so a close waits for it rather than stopping it. **Test case:** Export a large map with `exportJsonMap()` and close the profile's tab while it runs; then `setDiscordDetail(string.rep("ä", 65))` and confirm the presence still updates; then `ttsQueue("queued line") ttsSpeak("first")` followed immediately by `ttsSpeak("second")` and confirm "second" is what gets spoken. Assisted-by: Claude:claude-opus-5
2026-08-07 06:10:42 +02:00
void cleanupTestCase() {}
improve: Give players full control over Discord Rich Presence (#9116) ## Summary Players had no clear way to control what Discord shows about their Mudlet activity. The old checkbox in the connection pane only gated server GMCP data but didn't prevent Discord from showing "Playing Mudlet", and the privacy controls were confusing. This PR replaces all of that with three straightforward modes via radio buttons in Profile Preferences > Chat: - **Show full game details (if supported)** - full game integration with server-provided presence (default) - **Show Mudlet only** - only shows "playing Mudlet", game server is not told about Discord - **Disabled** - Discord shows nothing about Mudlet Players pick the mode that matches their comfort level, and the existing privacy checkboxes (hide detail, hide state, etc.) remain available in Game details mode for finer control. ### What changed - **Three-mode radio buttons** in Profile Preferences > Chat with a two-column layout (modes on the left, privacy controls on the right), replacing the old connection-pane checkbox - **Server-origin tracking** so privacy checkboxes only gate data sent by the game server - Lua API calls always pass through (only Disabled mode blocks Lua entirely) - **Mid-session mode switching** via dynamic GMCP negotiation (Core.Supports.Add/Remove + External.Discord.Hello/Get) - **Deferred RPC init** - Discord RPC now starts when a profile loads, not on app launch - **Username restriction improvements** - takes effect immediately, case-insensitive (Discord usernames are lowercase-only since 2023), shuts down RPC when mismatched - **Shows logged-in Discord user** in preferences next to the restriction field, with a tooltip explaining the desktop app requirement when not connected - **Presence fix** - empty string fields now send nullptr so Discord hides them instead of showing blanks - **Memory leak fix** - presence allocations are now freed in the destructor regardless of RPC state ### Cleanup - Removed obsolete discriminator field (`mRequiredDiscordUserDiscriminator`) - Discord removed discriminators in 2023 - Removed dead code (`getDiscordUserDetails()`, never called) - Restored `Discord_ClearPresence` function pointer for potential future use - Use proper `Host::DiscordOptionFlags` types instead of raw `int` (thanks @SlySven) ### Known quirks - The "Hide timer" checkbox correctly omits timestamps from presence data, but Discord's client starts its own activity timer for any presence without a timestamp - this is Discord client behavior outside our control. - The "Hide large icon" setting clears the image key, but some Discord clients fall back to the application's default icon instead of hiding it entirely. ### Test plan - [ ] Open Profile Preferences > Chat tab - [ ] Switch between the three radio button modes and verify Discord presence updates accordingly - [ ] In Game details mode, toggle privacy checkboxes and verify fields are hidden/shown - [ ] Use Lua API (e.g. `setDiscordDetail("test")`) in Mudlet only mode - should work. Try in Disabled mode - should fail with error - [ ] Set a username restriction and verify presence clears immediately if mismatched - [ ] Run unit tests: `cd build && ./test/DiscordTest` - [ ] Run functional tests: `cd build && ctest -R TDiscordModeTest -V` Closes #6967. Supersedes #7438. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: SlySven <slysven@virginmedia.com>
2026-04-08 13:33:09 +02:00
};
#include "DiscordTest.moc"
QTEST_MAIN(DiscordTest)