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>
|
2026-07-26 10:13:28 +02:00
|
|
|
#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")));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-26 10:13:28 +02:00
|
|
|
// 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)
|