mudlet/src/discord.h
Vadim Peretokin c0e1fac7cb
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

308 lines
13 KiB
C++

#ifndef DISCORD_H
#define DISCORD_H
/***************************************************************************
* Copyright (C) 2018 by Vadim Peretokin - vperetokin@gmail.com *
* Copyright (C) 2018, 2022 by Stephen Lyons - slysven@virginmedia.com *
* *
* 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 "Host.h"
#include <functional>
#include <map>
#include <memory>
#include <utility>
#include <QDebug>
#include <QTimer>
#include <QTimerEvent>
#include <QLibrary>
#include "../3rdparty/discord/rpc/include/discord_register.h"
#include "../3rdparty/discord/rpc/include/discord_rpc.h"
/*
* From the discord headers and on-line documentation:
* typedef struct DiscordRichPresence {
* const char* state; // max 128 bytes
* const char* details; // max 128 bytes
* int64_t startTimestamp;
* int64_t endTimestamp;
* const char* largeImageKey; // max 32 bytes
* const char* largeImageText; // max 128 bytes
* const char* smallImageKey; // max 32 bytes
* const char* smallImageText; // max 128 bytes
* const char* partyId; // max 128 bytes
* int partySize;
* int partyMax;
* const char* matchSecret; // max 128 bytes
* const char* joinSecret; // max 128 bytes
* const char* spectateSecret; // max 128 bytes
* int8_t instance;
* } DiscordRichPresence;
*
*
* typedef struct DiscordUser {
* const char* userId; // max 32 bytes
* const char* username; // max 344 bytes
* const char* discriminator; // max 8 bytes
* const char* avatar; // max 128 bytes
* } DiscordUser;
*/
// This is used to hold data to be stuffed into a DiscordRichPresence before
// it is sent to the RPC library with an Discord_UpdatePresence(...) call.
// It is done this way because the definition we have for the
// DiscordRichPresence is filled with const char pointers that can only be
// set on instantiation.
class localDiscordPresence {
public:
localDiscordPresence()
: mState()
, mDetails()
, mLargeImageKey()
, mLargeImageText()
, mSmallImageKey()
, mSmallImageText()
, mPartyId()
, mMatchSecret()
, mJoinSecret()
, mSpectateSecret()
{
}
void setStateText(const QString&);
void setDetailText(const QString&);
void setStartTimeStamp(int64_t startTime) { mStartTimestamp = startTime; }
void setEndTimeStamp(int64_t endTime) { mEndTimestamp = endTime; }
void setLargeImageKey(const QString&);
void setLargeImageText(const QString&);
void setSmallImageKey(const QString&);
void setSmallImageText(const QString&);
void setJoinSecret(const QString&);
void setMatchSecret(const QString&);
void setSpectateSecret(const QString&);
void setPartySize(const int size) { mPartySize = size; }
void setPartyMax(const int maximum) { mPartyMax = maximum; }
DiscordRichPresence convert() const;
QString getStateText() const { return mState; }
QString getDetailText() const { return mDetails; }
int64_t getStartTimeStamp() const { return mStartTimestamp; }
int64_t getEndTimeStamp() const { return mEndTimestamp; }
QString getLargeImageKey() const { return mLargeImageKey; }
QString getLargeImageText() const { return mLargeImageText; }
QString getSmallImageKey() const { return mSmallImageKey; }
QString getSmallImageText() const { return mSmallImageText; }
QString getJoinSecret() const { return mJoinSecret; }
QString getMatchSecret() const { return mMatchSecret; }
QString getSpectateSecret() const { return mSpectateSecret; }
QString getPartyId() const { return mPartyId; }
int getPartySize() const { return mPartySize; }
int getPartyMax() const { return mPartyMax; }
int8_t getInstance() const { return mInstance; }
private:
// The limits Discord documents for each field, in bytes (see the struct
// comment above). The buffers are one byte larger than the limit they hold:
// sized at exactly the limit, the null terminator would take the last byte
// and a field of the full documented length would always lose its final
// character. Named in the 'k' form the rest of the codebase gives an array
// size (TArea.cpp's kPixmapDataLineSize) rather than the 'scm' one it gives
// other static class members.
static constexpr size_t kTextByteLimit = 128;
static constexpr size_t kImageKeyByteLimit = 32;
char mState[kTextByteLimit + 1];
char mDetails[kTextByteLimit + 1];
int64_t mStartTimestamp = 0;
int64_t mEndTimestamp = 0;
char mLargeImageKey[kImageKeyByteLimit + 1];
char mLargeImageText[kTextByteLimit + 1];
char mSmallImageKey[kImageKeyByteLimit + 1];
char mSmallImageText[kTextByteLimit + 1];
char mPartyId[kTextByteLimit + 1];
int mPartySize = 0;
int mPartyMax = 0;
char mMatchSecret[kTextByteLimit + 1];
char mJoinSecret[kTextByteLimit + 1];
char mSpectateSecret[kTextByteLimit + 1];
int8_t mInstance = 1;
};
#ifndef QT_NO_DEBUG_STREAM
// Note "inline" is REQUIRED:
inline QDebug& operator<<(QDebug& debug, const localDiscordPresence& ldp)
{
const QDebugStateSaver saver(debug);
Q_UNUSED(saver)
QString result = qsl("localDiscordPresence(\n"
" mDetails: \"%1\" mState: \"%2\" mInstance: %3\n"
" mLargeImageKey: \"%4\" mLargeImageText: \"%5\" \n"
" mSmallImageKey: \"%6\" mSmallImageText: \"%7\" \n")
.arg(ldp.getDetailText(), ldp.getStateText(),
QString::number(ldp.getInstance()),
ldp.getLargeImageKey(), ldp.getLargeImageText(),
ldp.getSmallImageKey(), ldp.getSmallImageText());
result.append(qsl(" mPartyId: \"%1\" mPartySize: %2 mPartyMax %3\n"
" mMatchSecret: \"%4\" mJoinSecret: \"%5\" mSpectateSecret \"%6\"\n"
" mStartTimeStamp: %7 mEndTimeStamp: %8)\n")
.arg(ldp.getPartyId(), QString::number(ldp.getPartySize()), QString::number(ldp.getPartyMax()),
ldp.getMatchSecret(), ldp.getJoinSecret(), ldp.getSpectateSecret(),
QString::number(ldp.getStartTimeStamp()), QString::number(ldp.getEndTimeStamp())));
debug.nospace().noquote() << result;
return debug;
}
#endif // QT_NO_DEBUG_STREAM
class Discord : public QObject
{
Q_OBJECT
// Allows the functional test to simulate the logged-in Discord user:
friend class TDiscordModeTest;
public:
explicit Discord(QObject *parent = nullptr);
~Discord() override;
bool libraryLoaded();
bool usingMudletsDiscordID(Host*) const;
static QString getLoggedInUserName() { return smUserName; }
void initializeRpc();
void shutdownRpc();
void UpdatePresence();
void setServerOrigin(Host*, const Host::DiscordOptionFlag);
void clearServerOrigin(Host*, const Host::DiscordOptionFlag);
bool isServerOrigin(Host*, const Host::DiscordOptionFlag) const;
QString deduceGameName(const QString& address);
QPair<bool, QString> gameIntegrationSupported(const QString& address);
void setLargeImage(Host*, const QString&);
void setLargeImageText(Host*, const QString&);
void setSmallImage(Host*, const QString&);
void setSmallImageText(Host*, const QString&);
void setStateText(Host*, const QString&);
void setDetailText(Host*, const QString&);
void setStartTimeStamp(Host*, int64_t);
void setEndTimeStamp(Host*, int64_t);
void setParty(Host*, int);
void setParty(Host*, int, int);
bool setApplicationID(Host*, const QString&);
void resetData(Host*);
QString getApplicationId(Host* pHost) const;
// These retrieve the cached data:
QString getDetailText(Host* pHost) const { return mDetailTexts.value(pHost); }
QString getStateText(Host* pHost) const { return mStateTexts.value(pHost); }
QString getLargeImage(Host* pHost) const { return mLargeImages.value(pHost); }
QString getLargeImageText(Host* pHost) const { return mLargeImageTexts.value(pHost); }
QString getSmallImage(Host* pHost) const { return mSmallImages.value(pHost); }
QString getSmallImageText(Host* pHost) const { return mSmallImageTexts.value(pHost); }
QPair<int64_t ,int64_t> getTimeStamps(Host* pHost) const { return qMakePair(mStartTimes.value(pHost), mEndTimes.value(pHost)); }
QPair<int, int> getParty(Host* pHost) const { return qMakePair(mPartySize.value(pHost), mPartyMax.value(pHost)); }
// Runs the Host::discordUserIdMatch(...) check for the given Host:
bool discordUserIdMatch(Host* pHost) const;
const static QString mMudletApplicationId;
private:
static void handleDiscordReady(const DiscordUser* request);
static void handleDiscordDisconnected(int errorCode, const char* message);
static void handleDiscordError(int errorCode, const char* message);
static void handleDiscordJoinGame(const char* joinSecret);
static void handleDiscordSpectateGame(const char* spectateSecret);
static void handleDiscordJoinRequest(const DiscordUser* request);
void timerEvent(QTimerEvent *event) override;
std::unique_ptr<DiscordEventHandlers> mpHandlers;
// These are function pointers to functions located in the Discord RPC library:
std::function<void(const char*, DiscordEventHandlers*, int, const char*)> Discord_Initialize;
std::function<void(const DiscordRichPresence*)> Discord_UpdatePresence;
std::function<void(void)> Discord_RunCallbacks;
std::function<void(void)> Discord_Shutdown;
// Could be useful for clearing presence without tearing down the RPC connection:
// std::function<void(void)> Discord_ClearPresence;
#if defined(DISCORD_DISABLE_IO_THREAD)
// std::function<void(void)> Discord_UpdateConnection;
#endif
// std::function<void(const char*, int)> Discord_Respond;
// std::function<void(DiscordEventHandlers*)> Discord_UpdateHandlers;
bool mLoaded = false;
bool mRpcActive = false;
bool mPendingPresenceUpdate = false;
// Key is a Application Id, Value is a pointer to a local copy of the data
// currently held for that presence:
std::map<QString, std::unique_ptr<localDiscordPresence>> mPresencePtrs;
// Used to tie a profile to a particular Discord presence - multiple
// profiles can have the same presence but defaults to the nullptr one for
// Mudlet:
QMap<Host*, QString>mHostApplicationIDs;
QScopedPointer<QLibrary> mpLibrary;
// Used to hold the per profile data independently of whichever application id
// it will be used with:
QMap<Host*, int64_t>mStartTimes;
QMap<Host*, int64_t>mEndTimes;
QMap<Host*, QString>mDetailTexts;
QMap<Host*, QString>mStateTexts;
QMap<Host*, QString>mLargeImages;
QMap<Host*, QString>mLargeImageTexts;
QMap<Host*, QString>mSmallImages;
QMap<Host*, QString>mSmallImageTexts;
QMap<Host*, int>mPartySize;
QMap<Host*, int>mPartyMax;
// Tracks which presence fields were last set by the server (vs Lua).
// Uses the same bit positions as Host::DiscordOptionFlag.
QMap<Host*, Host::DiscordOptionFlags> mServerOriginFlags;
// Hash with game name as key and various URL forms that might be used for
// it as values:
QHash<QString, QVector<QString>> mKnownGames;
// The application ID that is currently the one that the Discord RPC library
// has been set to use - only ONE can be active at a time currently,
// though an Issue does exist to revise that at Discord:
// https://github.com/discordapp/discord-rpc/issues/202
QString mCurrentApplicationId;
// These are needed to validate the local user's presence on Discord to
// the one that they want to be associated with a profile's character name
// - it may be desired to not reveal the character name on Discord until
// that has confirmed that a currently active Discord/Discord-PTB/
// Discord-Canary application is using the expected User identity (reflected
// in the User Avatar image and name within that application).
static QString smUserName;
static QString smUserId;
static QString smAvatar;
};
#endif // DISCORD_H