/*************************************************************************** * 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 #include #include 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(mpLibrary->resolve("Discord_Initialize")); Discord_UpdatePresence = reinterpret_cast(mpLibrary->resolve("Discord_UpdatePresence")); Discord_RunCallbacks = reinterpret_cast(mpLibrary->resolve("Discord_RunCallbacks")); Discord_Shutdown = reinterpret_cast(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(); 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()); } // 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()); 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> itServer(mKnownGames); while (itServer.hasNext()) { itServer.next(); QVectorIterator 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 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); }