mirror of
https://github.com/Mudlet/Mudlet
synced 2026-08-13 18:26:27 -04:00
#### 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
750 lines
26 KiB
C++
750 lines
26 KiB
C++
/***************************************************************************
|
|
* Copyright (C) 2018 by Vadim Peretokin - vperetokin@gmail.com *
|
|
* Copyright (C) 2018-2019, 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 "discord.h"
|
|
#include "mudlet.h"
|
|
#include "utils.h"
|
|
|
|
#include <QtDebug>
|
|
#include <QHash>
|
|
#include <chrono>
|
|
|
|
using namespace std::chrono_literals;
|
|
|
|
// Uncomment this to provide some additional qDebug() output:
|
|
// #define DEBUG_DISCORD 1
|
|
|
|
QString Discord::smUserName;
|
|
QString Discord::smUserId;
|
|
QString Discord::smAvatar;
|
|
const QString Discord::mMudletApplicationId = qsl("450571881909583884");
|
|
|
|
Discord::Discord(QObject* parent)
|
|
: QObject(parent)
|
|
// For details see https://discord.com/developers/docs/rich-presence/how-to#initialization
|
|
// Initialise with a nullptr one with Mudlet's own ID
|
|
// NB: for testing the following MUDs have registered:
|
|
// "midmud" is "460618737712889858", has "server-icon", "exventure" and "mudlet" icons
|
|
// "carinus" is "438335628942376960", has "server-icon" and "mudlet" icons
|
|
// "wotmud" is "464945517156106240", has "mudlet", "ajar_(red|green|yellow|blue|white|grey|brown)"
|
|
, mHostApplicationIDs{{nullptr, mMudletApplicationId}} // lowercase list of known games
|
|
// {game name, {game addresses}}
|
|
, mKnownGames{
|
|
{"midmud", {"midmud.com"}},
|
|
{"wotmud", {"game.wotmud.org"}},
|
|
{"luminari", {"luminarimud.com"}},
|
|
{"achaea", {"achaea.com", "iron-ach.ironrealms.com"}},
|
|
{"aetolia", {"aetolia.com", "iron-aet.ironrealms.com"}},
|
|
{"imperian", {"imperian.com", "iron-imp.ironrealms.com"}},
|
|
{"lusternia", {"lusternia.com", "iron-lus.ironrealms.com"}},
|
|
{"starmourn", {"starmourn.com"}},
|
|
{"stickmud", {"stickmud.com"}},
|
|
{"clessidra", {"clessidra.it", "mud.clessidra.it"}},
|
|
{"mume", {"mume.org"}},
|
|
{"asteria", {"asteriamud.com"}},
|
|
}
|
|
{
|
|
#if defined(Q_OS_WIN64)
|
|
// Only defined on 64 bit Windows
|
|
mpLibrary.reset(new QLibrary(qsl("discord-rpc64")));
|
|
#elif defined(Q_OS_WINDOWS)
|
|
// Defined on both 32 and 64 bit Windows
|
|
mpLibrary.reset(new QLibrary(qsl("discord-rpc32")));
|
|
#else
|
|
// All other OSes
|
|
mpLibrary.reset(new QLibrary(qsl("discord-rpc")));
|
|
#endif
|
|
|
|
using Discord_InitializePrototype = void (*)(const char*, DiscordEventHandlers*, int, const char*);
|
|
using Discord_UpdatePresencePrototype = void (*)(const DiscordRichPresence*);
|
|
using Discord_RunCallbacksPrototype = void (*)();
|
|
using Discord_ShutdownPrototype = void (*)();
|
|
|
|
Discord_Initialize = reinterpret_cast<Discord_InitializePrototype>(mpLibrary->resolve("Discord_Initialize"));
|
|
Discord_UpdatePresence = reinterpret_cast<Discord_UpdatePresencePrototype>(mpLibrary->resolve("Discord_UpdatePresence"));
|
|
Discord_RunCallbacks = reinterpret_cast<Discord_RunCallbacksPrototype>(mpLibrary->resolve("Discord_RunCallbacks"));
|
|
Discord_Shutdown = reinterpret_cast<Discord_ShutdownPrototype>(mpLibrary->resolve("Discord_Shutdown"));
|
|
|
|
if (!mpLibrary->isLoaded() || !Discord_Initialize || !Discord_UpdatePresence || !Discord_RunCallbacks || !Discord_Shutdown) {
|
|
const auto msg = mpLibrary->errorString();
|
|
auto notFound = msg.contains(qsl("not found")) || msg.contains(qsl("No such file or directory"));
|
|
qDebug().nospace() << "Could not " << (notFound ? "find" : "load") << " Discord library - searched in:";
|
|
for (const auto& libraryPath : qApp->libraryPaths()) {
|
|
qDebug() << " " << libraryPath;
|
|
}
|
|
if (!msg.isEmpty() && !notFound) {
|
|
qDebug().noquote().nospace() << " error: \"" << msg << "\".";
|
|
}
|
|
return;
|
|
}
|
|
|
|
mLoaded = true;
|
|
qDebug() << "Discord integration loaded. Using functions from:" << mpLibrary.data()->fileName();
|
|
|
|
mpHandlers = std::make_unique<DiscordEventHandlers>();
|
|
memset(mpHandlers.get(), 0, sizeof(DiscordEventHandlers));
|
|
mpHandlers->ready = handleDiscordReady;
|
|
mpHandlers->errored = handleDiscordError;
|
|
mpHandlers->disconnected = handleDiscordDisconnected;
|
|
mpHandlers->joinGame = handleDiscordJoinGame;
|
|
mpHandlers->spectateGame = handleDiscordSpectateGame;
|
|
mpHandlers->joinRequest = handleDiscordJoinRequest;
|
|
|
|
// Don't initialize RPC until a profile is loaded - UpdatePresence will
|
|
// call initializeRpc() on demand when there's an active host.
|
|
|
|
// mudlet instance is not available in this constructor as it's still being initialised, so postpone the connection
|
|
QTimer::singleShot(0ms, this, [this]() {
|
|
Q_ASSERT(mudlet::self());
|
|
connect(mudlet::self(), &mudlet::signal_tabChanged, this, &Discord::UpdatePresence);
|
|
|
|
// process Discord callbacks every 50ms once we are all set up:
|
|
startTimer(50ms);
|
|
});
|
|
}
|
|
|
|
void Discord::initializeRpc()
|
|
{
|
|
if (!mLoaded || mRpcActive) {
|
|
return;
|
|
}
|
|
|
|
mCurrentApplicationId = mHostApplicationIDs.value(nullptr);
|
|
Discord_Initialize(mCurrentApplicationId.toUtf8().constData(), mpHandlers.get(), 0, nullptr);
|
|
mRpcActive = true;
|
|
}
|
|
|
|
void Discord::shutdownRpc()
|
|
{
|
|
if (!mRpcActive) {
|
|
return;
|
|
}
|
|
|
|
Discord_Shutdown();
|
|
mRpcActive = false;
|
|
mCurrentApplicationId.clear();
|
|
}
|
|
|
|
Discord::~Discord()
|
|
{
|
|
if (mRpcActive) {
|
|
Discord_Shutdown();
|
|
// We might expect to have to do an mpLibrary->unload() but we do not
|
|
// need to as it happens automagically on the application shutdown...
|
|
}
|
|
|
|
// Clear out the localDiscordPresence collection:
|
|
mPresencePtrs.clear();
|
|
}
|
|
|
|
// For all the setters below the caller is supposed to check that they have the
|
|
// permission to do the operation
|
|
void Discord::setDetailText(Host* pHost, const QString& text)
|
|
{
|
|
if (!text.isEmpty()) {
|
|
mDetailTexts[pHost] = text;
|
|
} else {
|
|
mDetailTexts[pHost] = tr("via Mudlet");
|
|
}
|
|
|
|
if (mLoaded) {
|
|
UpdatePresence();
|
|
}
|
|
}
|
|
|
|
void Discord::setStateText(Host* pHost, const QString& text)
|
|
{
|
|
mStateTexts[pHost] = text;
|
|
if (mLoaded) {
|
|
UpdatePresence();
|
|
}
|
|
}
|
|
|
|
void Discord::setLargeImage(Host* pHost, const QString& text)
|
|
{
|
|
mLargeImages[pHost] = text;
|
|
if (mLoaded) {
|
|
UpdatePresence();
|
|
}
|
|
}
|
|
|
|
void Discord::setLargeImageText(Host* pHost, const QString& text)
|
|
{
|
|
mLargeImageTexts[pHost] = text;
|
|
if (mLoaded) {
|
|
UpdatePresence();
|
|
}
|
|
}
|
|
|
|
void Discord::setSmallImage(Host* pHost, const QString& text)
|
|
{
|
|
mSmallImages[pHost] = text;
|
|
if (mLoaded) {
|
|
UpdatePresence();
|
|
}
|
|
}
|
|
|
|
void Discord::setSmallImageText(Host* pHost, const QString& text)
|
|
{
|
|
mSmallImageTexts[pHost] = text;
|
|
if (mLoaded) {
|
|
UpdatePresence();
|
|
}
|
|
}
|
|
|
|
void Discord::setStartTimeStamp(Host* pHost, int64_t epochTimeStamp)
|
|
{
|
|
mStartTimes[pHost] = epochTimeStamp;
|
|
mEndTimes.remove(pHost);
|
|
if (mLoaded) {
|
|
UpdatePresence();
|
|
}
|
|
}
|
|
|
|
void Discord::setEndTimeStamp(Host* pHost, int64_t epochTimeStamp)
|
|
{
|
|
mEndTimes[pHost] = epochTimeStamp;
|
|
mStartTimes.remove(pHost);
|
|
if (mLoaded) {
|
|
UpdatePresence();
|
|
}
|
|
}
|
|
|
|
void Discord::setParty(Host* pHost, int partySize)
|
|
{
|
|
const int validPartySize = qMax(0, partySize);
|
|
if (validPartySize) {
|
|
// Is more than zero:
|
|
if (mPartyMax.value(pHost) < validPartySize) {
|
|
mPartyMax[pHost] = validPartySize;
|
|
}
|
|
|
|
mPartySize[pHost] = validPartySize;
|
|
} else if (mPartyMax.contains(pHost)) {
|
|
// There is a max size set - so zero this value
|
|
mPartySize[pHost] = 0;
|
|
} else {
|
|
// There isn't a party size set so remove this (zero) value
|
|
mPartySize.remove(pHost);
|
|
}
|
|
if (mLoaded) {
|
|
UpdatePresence();
|
|
}
|
|
}
|
|
|
|
void Discord::setParty(Host* pHost, int partySize, int partyMax)
|
|
{
|
|
const int validPartySize = qMax(0, partySize);
|
|
const int validPartyMax = qMax(0, partyMax);
|
|
|
|
if (validPartyMax) {
|
|
// We have a party max size that is a positive number - so use the
|
|
// largest of it and the size as the maximum:
|
|
mPartyMax[pHost] = qMax(validPartySize, validPartyMax);
|
|
mPartySize[pHost] = validPartySize;
|
|
} else {
|
|
// We have explicitly set the party maximum size to 0 (or less) - so
|
|
// clear things:
|
|
mPartySize.remove(pHost);
|
|
mPartyMax.remove(pHost);
|
|
}
|
|
if (mLoaded) {
|
|
UpdatePresence();
|
|
}
|
|
}
|
|
|
|
void Discord::timerEvent(QTimerEvent* event)
|
|
{
|
|
Q_UNUSED(event)
|
|
|
|
if (mLoaded && mRpcActive) {
|
|
Discord_RunCallbacks();
|
|
if (mPendingPresenceUpdate) {
|
|
mPendingPresenceUpdate = false;
|
|
UpdatePresence();
|
|
}
|
|
}
|
|
}
|
|
|
|
void Discord::handleDiscordReady(const DiscordUser* request)
|
|
{
|
|
Discord::smUserName = request->username;
|
|
Discord::smUserId = request->userId;
|
|
Discord::smAvatar = request->avatar;
|
|
|
|
#if defined(DEBUG_DISCORD)
|
|
qDebug().noquote().nospace() << "Discord Ready callback received - for UserName: \"" << smUserName << "\", ID: \"" << smUserId << "\".";
|
|
#endif
|
|
// Don't call UpdatePresence directly from here - re-entering the Discord
|
|
// library from a callback freezes Mudlet. Instead, signal the timer to
|
|
// pick it up on the next tick.
|
|
mudlet::self()->mDiscord.mPendingPresenceUpdate = true;
|
|
}
|
|
|
|
void Discord::handleDiscordDisconnected(int errorCode, const char* message)
|
|
{
|
|
qWarning() << "Discord disconnected - code:" << errorCode << "message:" << message;
|
|
}
|
|
|
|
void Discord::handleDiscordError(int errorCode, const char* message)
|
|
{
|
|
qWarning() << "Discord error - code:" << errorCode << "message:" << message;
|
|
}
|
|
|
|
void Discord::handleDiscordJoinGame(const char* joinSecret)
|
|
{
|
|
qDebug() << "Discord JoinGame received with secret:" << joinSecret;
|
|
}
|
|
|
|
void Discord::handleDiscordSpectateGame(const char* spectateSecret)
|
|
{
|
|
qDebug() << "Discord SpectateGame received with secret:" << spectateSecret;
|
|
}
|
|
|
|
void Discord::handleDiscordJoinRequest(const DiscordUser* request)
|
|
{
|
|
qDebug() << "Discord JoinRequest received from user:" << request->username << "userId:" << request->userId << "avatar:" << request->avatar;
|
|
}
|
|
|
|
void Discord::UpdatePresence()
|
|
{
|
|
if (!mLoaded) {
|
|
return;
|
|
}
|
|
|
|
auto pHost = mudlet::self()->getActiveHost();
|
|
|
|
// Don't send any presence when no profile is active or Discord is
|
|
// disabled - showing "Playing Mudlet" would leak information when the
|
|
// user hasn't opted in (see issue #6967)
|
|
if (!pHost || pHost->mDiscordMode == Host::DiscordDisabled) {
|
|
if (mRpcActive) {
|
|
shutdownRpc();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!mRpcActive) {
|
|
initializeRpc();
|
|
// Don't send presence yet - wait for the handleDiscordReady callback
|
|
// to signal that the IPC handshake is complete
|
|
return;
|
|
}
|
|
|
|
if (pHost->mDiscordMode == Host::DiscordShowMudletOnly) {
|
|
// Ensure we're using the default Mudlet application ID
|
|
if (mCurrentApplicationId != mHostApplicationIDs.value(nullptr)) {
|
|
shutdownRpc();
|
|
initializeRpc();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!pHost->discordUserIdMatch(Discord::smUserName)) {
|
|
#if defined(DEBUG_DISCORD)
|
|
qDebug().nospace().noquote() << "Discord::UpdatePresence() INFO - Discord UserName does not match, not sending this update!";
|
|
#endif
|
|
if (mRpcActive) {
|
|
shutdownRpc();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Need to establish which presence to use - will be null if it has not been overridden:
|
|
QString applicationID = mHostApplicationIDs.value(pHost);
|
|
|
|
if (mPresencePtrs.empty()) {
|
|
// First time only - with no localDiscordPresence in collection,
|
|
// must just create the default one:
|
|
mPresencePtrs.emplace(QString(), std::make_unique<localDiscordPresence>());
|
|
}
|
|
|
|
// If the localDiscordPresence applicationID is NOT present in the existing
|
|
// map then this will return a nullptr:
|
|
localDiscordPresence* pDiscordPresence = nullptr;
|
|
if (applicationID.isEmpty()) {
|
|
auto it = mPresencePtrs.find(QString());
|
|
pDiscordPresence = (it != mPresencePtrs.end()) ? it->second.get() : nullptr;
|
|
// Reset the empty applicationID to the one that belongs to Mudlet:
|
|
applicationID = mHostApplicationIDs.value(nullptr);
|
|
|
|
Q_ASSERT_X(pDiscordPresence, "Discord", "no Discord presence available for Mudlets default presence");
|
|
} else {
|
|
auto it = mPresencePtrs.find(applicationID);
|
|
pDiscordPresence = (it != mPresencePtrs.end()) ? it->second.get() : nullptr;
|
|
|
|
if (!pDiscordPresence) {
|
|
auto [newIt, inserted] = mPresencePtrs.emplace(applicationID, std::make_unique<localDiscordPresence>());
|
|
pDiscordPresence = newIt->second.get();
|
|
}
|
|
}
|
|
|
|
if (mCurrentApplicationId != applicationID) {
|
|
#if defined(DEBUG_DISCORD)
|
|
qDebug().nospace().noquote() << "Discord::UpdatePresence() INFO - mCurrentApplicationId (\"" << mCurrentApplicationId << "\") does not match the one for this Host instance (\""
|
|
<< applicationID << "\"), restarting RPC library with the latter.";
|
|
#endif
|
|
Discord_Shutdown();
|
|
Discord_Initialize(applicationID.toUtf8().constData(), mpHandlers.get(), 0, nullptr);
|
|
mCurrentApplicationId = applicationID;
|
|
// Wait for the ready callback before sending presence
|
|
return;
|
|
}
|
|
|
|
if (!pDiscordPresence) {
|
|
qCritical().noquote() << "Discord::UpdatePresence() CRITICAL - pDiscordPresence is unexpectedly a nullptr, unable to proceed with this procedure, please report this to Mudlet Makers!";
|
|
return;
|
|
}
|
|
|
|
// Helper to decide if a field should be shown. Server-origin fields are
|
|
// subject to mode and privacy flags; Lua-origin fields always pass.
|
|
const bool isShowGameDetails = (pHost->mDiscordMode == Host::DiscordShowGameDetails);
|
|
const auto shouldShow = [&](Host::DiscordOptionFlag flag) -> bool {
|
|
if (!isServerOrigin(pHost, flag)) {
|
|
return true;
|
|
}
|
|
// Server-origin: only show in ShowGameDetails mode when the privacy flag allows it
|
|
return isShowGameDetails && (pHost->mDiscordAccessFlags & flag);
|
|
};
|
|
|
|
if (shouldShow(Host::DiscordSetDetail)) {
|
|
pDiscordPresence->setDetailText(mDetailTexts.value(pHost));
|
|
} else {
|
|
pDiscordPresence->setDetailText(QString());
|
|
}
|
|
|
|
if (shouldShow(Host::DiscordSetState)) {
|
|
pDiscordPresence->setStateText(mStateTexts.value(pHost));
|
|
} else {
|
|
pDiscordPresence->setStateText(QString());
|
|
}
|
|
|
|
if (shouldShow(Host::DiscordSetLargeIcon)) {
|
|
auto image = mLargeImages.value(pHost);
|
|
if (image.isEmpty() && applicationID == mMudletApplicationId) {
|
|
image = qsl("mudlet");
|
|
}
|
|
pDiscordPresence->setLargeImageKey(image);
|
|
} else {
|
|
pDiscordPresence->setLargeImageKey(QString());
|
|
}
|
|
|
|
if (shouldShow(Host::DiscordSetLargeIconText)) {
|
|
pDiscordPresence->setLargeImageText(mLargeImageTexts.value(pHost));
|
|
} else {
|
|
pDiscordPresence->setLargeImageText(QString());
|
|
}
|
|
|
|
if (shouldShow(Host::DiscordSetSmallIcon)) {
|
|
pDiscordPresence->setSmallImageKey(mSmallImages.value(pHost));
|
|
} else {
|
|
pDiscordPresence->setSmallImageKey(QString());
|
|
}
|
|
|
|
if (shouldShow(Host::DiscordSetSmallIconText)) {
|
|
pDiscordPresence->setSmallImageText(mSmallImageTexts.value(pHost));
|
|
} else {
|
|
pDiscordPresence->setSmallImageText(QString());
|
|
}
|
|
|
|
if (shouldShow(Host::DiscordSetPartyInfo) && mPartyMax.value(pHost)) {
|
|
pDiscordPresence->setPartySize(mPartySize.value(pHost));
|
|
pDiscordPresence->setPartyMax(mPartyMax.value(pHost));
|
|
} else {
|
|
pDiscordPresence->setPartySize(0);
|
|
pDiscordPresence->setPartyMax(0);
|
|
}
|
|
|
|
if (shouldShow(Host::DiscordSetTimeInfo)) {
|
|
if (mEndTimes.value(pHost)) {
|
|
pDiscordPresence->setEndTimeStamp(mEndTimes.value(pHost));
|
|
pDiscordPresence->setStartTimeStamp(0);
|
|
} else {
|
|
pDiscordPresence->setEndTimeStamp(0);
|
|
pDiscordPresence->setStartTimeStamp(mStartTimes.value(pHost, 0));
|
|
}
|
|
} else {
|
|
pDiscordPresence->setEndTimeStamp(0);
|
|
pDiscordPresence->setStartTimeStamp(0);
|
|
}
|
|
|
|
#if defined(DEBUG_DISCORD)
|
|
qDebug().nospace().noquote() << "Discord::UpdatePresence() INFO - sending update:\n" << *pDiscordPresence;
|
|
#endif
|
|
DiscordRichPresence const convertedPresence(pDiscordPresence->convert());
|
|
Discord_UpdatePresence(&convertedPresence);
|
|
}
|
|
|
|
QString Discord::deduceGameName(const QString& address)
|
|
{
|
|
// Handle using localhost as an off-line testing case
|
|
if (address == QLatin1String("localhost") || address == QLatin1String("127.0.0.1") || address == QLatin1String("::1")) {
|
|
return qsl("localhost");
|
|
}
|
|
|
|
// Handle the cases where the server url contains the "well-known" Server
|
|
// name - that being the key of the QHash mKnownGames:
|
|
if (mKnownGames.contains(address)) {
|
|
return address;
|
|
}
|
|
|
|
// Do a bit of URL processing on the (potentially) host url:
|
|
QString otherName;
|
|
switch (address.count(QChar('.'))) {
|
|
default:
|
|
// Too complex - abandon
|
|
qDebug().noquote().noquote() << "Discord::deduceGameName(\"" << address << "\") WARN - Unable to deduce MUD name from given address.";
|
|
break;
|
|
case 2: {
|
|
// three terms - assume last is a TLD so remove it but the first may be significant
|
|
|
|
QStringList fragments = address.split(QChar('.'));
|
|
fragments.removeLast();
|
|
otherName = fragments.join(QLatin1String("."));
|
|
if (otherName.startsWith(QLatin1String("game."))) {
|
|
// WoTMUD type case - so take remaining term in the middle of original
|
|
otherName = otherName.split(QChar('.')).last();
|
|
break;
|
|
}
|
|
if (otherName.startsWith(QLatin1String("www."))) {
|
|
// Error(?) in entering details so that a web-server name was given:
|
|
otherName = otherName.split(QChar('.')).last();
|
|
break;
|
|
}
|
|
}
|
|
otherName.clear();
|
|
break;
|
|
case 1:
|
|
// two terms - assume last is a TLD so remove it
|
|
otherName = address.split(QChar('.')).first();
|
|
break;
|
|
case 0:
|
|
// single term no need to split it
|
|
otherName = address;
|
|
break;
|
|
}
|
|
|
|
if (address.endsWith(qsl(".com"))) {
|
|
otherName = address.left(address.length() - 4);
|
|
} else if (address.endsWith(qsl(".de"))) {
|
|
// Handle avalon.de case
|
|
otherName = address.left(address.length() - 4);
|
|
}
|
|
|
|
// Handle the remaining cases where the known URL is something else - like
|
|
// say a fixed IP address stored as a member of the value for the QHash
|
|
// mKnownGames:
|
|
QHashIterator<QString, QVector<QString>> itServer(mKnownGames);
|
|
while (itServer.hasNext()) {
|
|
itServer.next();
|
|
QVectorIterator<QString> itUrl(itServer.value());
|
|
while (itUrl.hasNext()) {
|
|
if (itUrl.next().contains(address)) {
|
|
return itServer.key();
|
|
}
|
|
}
|
|
}
|
|
|
|
// This may be an empty string but it is the best guess otherwise:
|
|
return otherName;
|
|
}
|
|
|
|
// Returns true in First if this is a MUD we know about (and have an Icon for in
|
|
// on the Mudlet Discord server!) and the deduced name in Second - if the
|
|
// first is true.
|
|
QPair<bool, QString> Discord::gameIntegrationSupported(const QString& address)
|
|
{
|
|
const QString deducedName = deduceGameName(address);
|
|
|
|
// Handle using localhost as an off-line testing case
|
|
if (deducedName == QLatin1String("localhost")) {
|
|
return qMakePair(true, deducedName);
|
|
}
|
|
return qMakePair((!deducedName.isEmpty() && mKnownGames.contains(deducedName)), deducedName);
|
|
}
|
|
|
|
bool Discord::libraryLoaded()
|
|
{
|
|
return mLoaded;
|
|
}
|
|
|
|
// AFAICT A Discord Application Id is an unsigned long long int (a.k.a. a
|
|
// quint64, or qulonglong)
|
|
bool Discord::setApplicationID(Host* pHost, const QString& text)
|
|
{
|
|
const QString oldID = mHostApplicationIDs.value(pHost);
|
|
if (oldID == text) {
|
|
// No change so do nothing
|
|
return true;
|
|
}
|
|
|
|
// Note what the current app ID is for the given Host - will be an empty
|
|
// string if not overridden from the default Mudlet one:
|
|
if (text.isEmpty()) {
|
|
// An empty or null string is the signal to switch back to default
|
|
// "Mudlet" presence - and always succeeds
|
|
mHostApplicationIDs.remove(pHost);
|
|
pHost->setDiscordApplicationID(QString());
|
|
UpdatePresence();
|
|
|
|
return true;
|
|
}
|
|
|
|
bool ok = false;
|
|
if (text.toLongLong(&ok) && ok) {
|
|
// Got something that makes a non-zero number - so assume it is ok
|
|
mHostApplicationIDs[pHost] = text;
|
|
pHost->setDiscordApplicationID(text);
|
|
UpdatePresence();
|
|
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void Discord::resetData(Host* pHost)
|
|
{
|
|
mStartTimes.remove(pHost);
|
|
mEndTimes.remove(pHost);
|
|
mDetailTexts.remove(pHost);
|
|
mStateTexts.remove(pHost);
|
|
mLargeImages.remove(pHost);
|
|
mLargeImageTexts.remove(pHost);
|
|
mSmallImages.remove(pHost);
|
|
mSmallImageTexts.remove(pHost);
|
|
mPartySize.remove(pHost);
|
|
mPartyMax.remove(pHost);
|
|
mHostApplicationIDs.remove(pHost);
|
|
mServerOriginFlags.remove(pHost);
|
|
UpdatePresence();
|
|
}
|
|
|
|
void Discord::setServerOrigin(Host* pHost, const Host::DiscordOptionFlag flag)
|
|
{
|
|
mServerOriginFlags[pHost] |= flag;
|
|
}
|
|
|
|
void Discord::clearServerOrigin(Host* pHost, const Host::DiscordOptionFlag flag)
|
|
{
|
|
mServerOriginFlags[pHost] &= ~flag;
|
|
}
|
|
|
|
bool Discord::isServerOrigin(Host* pHost, const Host::DiscordOptionFlag flag) const
|
|
{
|
|
return mServerOriginFlags.value(pHost, Host::DiscordNoOption) & flag;
|
|
}
|
|
|
|
// Returns Host set app ID or the default Mudlet one if none set for the
|
|
// specific Host:
|
|
QString Discord::getApplicationId(Host* pHost) const
|
|
{
|
|
return mHostApplicationIDs.value(pHost, mHostApplicationIDs.value(nullptr));
|
|
}
|
|
|
|
DiscordRichPresence localDiscordPresence::convert() const
|
|
{
|
|
// Discord RPC distinguishes between nullptr (field not set) and ""
|
|
// (field set to empty). Pass nullptr for empty strings so Discord
|
|
// hides the field rather than showing it as blank.
|
|
const auto nullIfEmpty = [](const char* str) -> const char* {
|
|
return (str && str[0] != '\0') ? str : nullptr;
|
|
};
|
|
|
|
return DiscordRichPresence{nullIfEmpty(mState),
|
|
nullIfEmpty(mDetails),
|
|
mStartTimestamp,
|
|
mEndTimestamp,
|
|
nullIfEmpty(mLargeImageKey),
|
|
nullIfEmpty(mLargeImageText),
|
|
nullIfEmpty(mSmallImageKey),
|
|
nullIfEmpty(mSmallImageText),
|
|
nullIfEmpty(mPartyId),
|
|
mPartySize,
|
|
mPartyMax,
|
|
nullIfEmpty(mMatchSecret),
|
|
nullIfEmpty(mJoinSecret),
|
|
nullIfEmpty(mSpectateSecret),
|
|
mInstance};
|
|
}
|
|
|
|
void localDiscordPresence::setDetailText(const QString& text)
|
|
{
|
|
const QByteArray utf8Data = text.toUtf8();
|
|
utils::copyUtf8String(mDetails, sizeof(mDetails), utf8Data.constData(), utf8Data.size());
|
|
}
|
|
|
|
void localDiscordPresence::setStateText(const QString& text)
|
|
{
|
|
const QByteArray utf8Data = text.toUtf8();
|
|
utils::copyUtf8String(mState, sizeof(mState), utf8Data.constData(), utf8Data.size());
|
|
}
|
|
|
|
void localDiscordPresence::setLargeImageText(const QString& text)
|
|
{
|
|
const QByteArray utf8Data = text.toUtf8();
|
|
utils::copyUtf8String(mLargeImageText, sizeof(mLargeImageText), utf8Data.constData(), utf8Data.size());
|
|
}
|
|
|
|
void localDiscordPresence::setLargeImageKey(const QString& text)
|
|
{
|
|
const QByteArray utf8Data = text.toUtf8();
|
|
utils::copyUtf8String(mLargeImageKey, sizeof(mLargeImageKey), utf8Data.constData(), utf8Data.size());
|
|
}
|
|
|
|
void localDiscordPresence::setSmallImageText(const QString& text)
|
|
{
|
|
const QByteArray utf8Data = text.toUtf8();
|
|
utils::copyUtf8String(mSmallImageText, sizeof(mSmallImageText), utf8Data.constData(), utf8Data.size());
|
|
}
|
|
|
|
void localDiscordPresence::setSmallImageKey(const QString& text)
|
|
{
|
|
const QByteArray utf8Data = text.toUtf8();
|
|
utils::copyUtf8String(mSmallImageKey, sizeof(mSmallImageKey), utf8Data.constData(), utf8Data.size());
|
|
}
|
|
|
|
void localDiscordPresence::setJoinSecret(const QString& text)
|
|
{
|
|
const QByteArray utf8Data = text.toUtf8();
|
|
utils::copyUtf8String(mJoinSecret, sizeof(mJoinSecret), utf8Data.constData(), utf8Data.size());
|
|
}
|
|
|
|
void localDiscordPresence::setMatchSecret(const QString& text)
|
|
{
|
|
const QByteArray utf8Data = text.toUtf8();
|
|
utils::copyUtf8String(mMatchSecret, sizeof(mMatchSecret), utf8Data.constData(), utf8Data.size());
|
|
}
|
|
|
|
void localDiscordPresence::setSpectateSecret(const QString& text)
|
|
{
|
|
const QByteArray utf8Data = text.toUtf8();
|
|
utils::copyUtf8String(mSpectateSecret, sizeof(mSpectateSecret), utf8Data.constData(), utf8Data.size());
|
|
}
|
|
|
|
bool Discord::usingMudletsDiscordID(Host* pHost) const
|
|
{
|
|
return (!mHostApplicationIDs.contains(pHost));
|
|
}
|
|
|
|
bool Discord::discordUserIdMatch(Host* pHost) const
|
|
{
|
|
return pHost->discordUserIdMatch(Discord::smUserName);
|
|
}
|