diff options
Diffstat (limited to 'protocols/Discord/src')
-rw-r--r-- | protocols/Discord/src/avatars.cpp | 205 | ||||
-rw-r--r-- | protocols/Discord/src/connection.cpp | 123 | ||||
-rw-r--r-- | protocols/Discord/src/dispatch.cpp | 592 | ||||
-rw-r--r-- | protocols/Discord/src/gateway.cpp | 346 | ||||
-rw-r--r-- | protocols/Discord/src/groupchat.cpp | 235 | ||||
-rw-r--r-- | protocols/Discord/src/guilds.cpp | 413 | ||||
-rw-r--r-- | protocols/Discord/src/http.cpp | 155 | ||||
-rw-r--r-- | protocols/Discord/src/main.cpp | 71 | ||||
-rw-r--r-- | protocols/Discord/src/menus.cpp | 172 | ||||
-rw-r--r-- | protocols/Discord/src/options.cpp | 100 | ||||
-rw-r--r-- | protocols/Discord/src/proto.cpp | 768 | ||||
-rw-r--r-- | protocols/Discord/src/proto.h | 476 | ||||
-rw-r--r-- | protocols/Discord/src/resource.h | 30 | ||||
-rw-r--r-- | protocols/Discord/src/server.cpp | 307 | ||||
-rw-r--r-- | protocols/Discord/src/stdafx.cxx | 18 | ||||
-rw-r--r-- | protocols/Discord/src/stdafx.h | 80 | ||||
-rw-r--r-- | protocols/Discord/src/utils.cpp | 376 | ||||
-rw-r--r-- | protocols/Discord/src/version.h | 13 | ||||
-rw-r--r-- | protocols/Discord/src/voice.cpp | 116 |
19 files changed, 4596 insertions, 0 deletions
diff --git a/protocols/Discord/src/avatars.cpp b/protocols/Discord/src/avatars.cpp new file mode 100644 index 0000000000..aef0a76e48 --- /dev/null +++ b/protocols/Discord/src/avatars.cpp @@ -0,0 +1,205 @@ +/* +Copyright © 2016-22 Miranda NG team + +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, see <http://www.gnu.org/licenses/>. +*/ + +#include "stdafx.h" + +CMStringW CDiscordProto::GetAvatarFilename(MCONTACT hContact) +{ + CMStringW wszResult(FORMAT, L"%s\\%S", VARSW(L"%miranda_avatarcache%"), m_szModuleName); + CreateDirectoryTreeW(wszResult); + + wszResult.AppendChar('\\'); + + const wchar_t* szFileType = ProtoGetAvatarExtension(getByte(hContact, "AvatarType", PA_FORMAT_PNG)); + wszResult.AppendFormat(L"%lld%s", getId(hContact, DB_KEY_ID), szFileType); + return wszResult; +} + +INT_PTR CDiscordProto::GetAvatarCaps(WPARAM wParam, LPARAM lParam) +{ + int res = 0; + + switch (wParam) { + case AF_MAXSIZE: + ((POINT*)lParam)->x = ((POINT*)lParam)->y = 128; + break; + + case AF_FORMATSUPPORTED: + res = lParam == PA_FORMAT_PNG || lParam == PA_FORMAT_GIF || lParam == PA_FORMAT_JPEG; + break; + + case AF_ENABLED: + case AF_DONTNEEDDELAYS: + case AF_FETCHIFPROTONOTVISIBLE: + case AF_FETCHIFCONTACTOFFLINE: + return 1; + } + + return res; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +void CDiscordProto::OnReceiveAvatar(NETLIBHTTPREQUEST *reply, AsyncHttpRequest *pReq) +{ + PROTO_AVATAR_INFORMATION ai = { 0 }; + ai.format = PA_FORMAT_UNKNOWN; + ai.hContact = (UINT_PTR)pReq->pUserInfo; + + if (reply->resultCode != 200) { +LBL_Error: + ProtoBroadcastAck(ai.hContact, ACKTYPE_AVATAR, ACKRESULT_FAILED, (HANDLE)&ai); + return; + } + + if (auto *pszHdr = Netlib_GetHeader(reply, "Content-Type")) + ai.format = ProtoGetAvatarFormatByMimeType(pszHdr); + + if (ai.format == PA_FORMAT_UNKNOWN) { + debugLogA("unknown avatar mime type"); + goto LBL_Error; + } + + setByte(ai.hContact, "AvatarType", ai.format); + mir_wstrncpy(ai.filename, GetAvatarFilename(ai.hContact), _countof(ai.filename)); + + FILE *out = _wfopen(ai.filename, L"wb"); + if (out == nullptr) { + debugLogA("cannot open avatar file %S for writing", ai.filename); + goto LBL_Error; + } + + fwrite(reply->pData, 1, reply->dataLength, out); + fclose(out); + + if (ai.hContact) + ProtoBroadcastAck(ai.hContact, ACKTYPE_AVATAR, ACKRESULT_SUCCESS, (HANDLE)&ai); + else + ReportSelfAvatarChanged(); +} + +bool CDiscordProto::RetrieveAvatar(MCONTACT hContact) +{ + ptrA szAvatarHash(getStringA(hContact, DB_KEY_AVHASH)); + SnowFlake id = getId(hContact, DB_KEY_ID); + if (id == 0 || szAvatarHash == nullptr) + return false; + + CMStringA szUrl(FORMAT, "https://cdn.discordapp.com/avatars/%lld/%s.jpg", id, szAvatarHash.get()); + AsyncHttpRequest *pReq = new AsyncHttpRequest(this, REQUEST_GET, szUrl, &CDiscordProto::OnReceiveAvatar); + pReq->pUserInfo = (void*)hContact; + Push(pReq); + return true; +} + +INT_PTR CDiscordProto::GetAvatarInfo(WPARAM flags, LPARAM lParam) +{ + PROTO_AVATAR_INFORMATION *pai = (PROTO_AVATAR_INFORMATION *)lParam; + + CMStringW wszFileName(GetAvatarFilename(pai->hContact)); + if (!wszFileName.IsEmpty()) { + mir_wstrncpy(pai->filename, wszFileName, _countof(pai->filename)); + + bool bFileExist = _waccess(wszFileName, 0) == 0; + + // if we still need to load an avatar + if ((flags & GAIF_FORCE) || !bFileExist) { + if (RetrieveAvatar(pai->hContact)) + return GAIR_WAITFOR; + } + else if (bFileExist) + return GAIR_SUCCESS; + } + + return GAIR_NOAVATAR; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +INT_PTR CDiscordProto::GetMyAvatar(WPARAM wParam, LPARAM lParam) +{ + if (!wParam || !lParam) + return -3; + + wchar_t* buf = (wchar_t*)wParam; + int size = (int)lParam; + + PROTO_AVATAR_INFORMATION ai = {}; + switch (GetAvatarInfo(0, (LPARAM)&ai)) { + case GAIR_SUCCESS: + wcsncpy_s(buf, size, ai.filename, _TRUNCATE); + return 0; + + case GAIR_WAITFOR: + return -1; + } + + return -2; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +INT_PTR CDiscordProto::SetMyAvatar(WPARAM, LPARAM lParam) +{ + CMStringW wszFileName(GetAvatarFilename(0)); + + const wchar_t *pwszFilename = (const wchar_t*)lParam; + if (pwszFilename == nullptr) { // remove my avatar file + delSetting(DB_KEY_AVHASH); + DeleteFile(wszFileName); + } + + CMStringA szPayload("data:"); + + const char *szMimeType = ProtoGetAvatarMimeType(ProtoGetAvatarFileFormat(pwszFilename)); + if (szMimeType == nullptr) { + debugLogA("invalid file format for avatar %S", pwszFilename); + return 1; + } + szPayload.AppendFormat("%s;base64,", szMimeType); + FILE *in = _wfopen(pwszFilename, L"rb"); + if (in == nullptr) { + debugLogA("cannot open avatar file %S for reading", pwszFilename); + return 2; + } + + int iFileLength = _filelength(_fileno(in)); + ptrA szFileContents((char*)mir_alloc(iFileLength)); + fread(szFileContents, 1, iFileLength, in); + fclose(in); + szPayload.Append(ptrA(mir_base64_encode(szFileContents.get(), iFileLength))); + + JSONNode root; root << CHAR_PARAM("avatar", szPayload); + Push(new AsyncHttpRequest(this, REQUEST_PATCH, "/users/@me", nullptr, &root)); + return 0; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +void CDiscordProto::CheckAvatarChange(MCONTACT hContact, const CMStringW &wszNewHash) +{ + if (wszNewHash.IsEmpty()) + return; + + ptrW wszOldAvatar(getWStringA(hContact, DB_KEY_AVHASH)); + + // if avatar's hash changed, we need to request a new one + if (mir_wstrcmp(wszNewHash, wszOldAvatar)) { + setWString(hContact, DB_KEY_AVHASH, wszNewHash); + RetrieveAvatar(hContact); + } +} diff --git a/protocols/Discord/src/connection.cpp b/protocols/Discord/src/connection.cpp new file mode 100644 index 0000000000..a85d5738a0 --- /dev/null +++ b/protocols/Discord/src/connection.cpp @@ -0,0 +1,123 @@ +/* +Copyright © 2016-22 Miranda NG team + +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, see <http://www.gnu.org/licenses/>. +*/ + +#include "stdafx.h" + +void CDiscordProto::ExecuteRequest(AsyncHttpRequest *pReq) +{ + CMStringA str; + + pReq->szUrl = pReq->m_szUrl.GetBuffer(); + if (!pReq->m_szParam.IsEmpty()) { + if (pReq->requestType == REQUEST_GET) { + str.Format("%s?%s", pReq->m_szUrl.c_str(), pReq->m_szParam.c_str()); + pReq->szUrl = str.GetBuffer(); + } + else { + pReq->pData = mir_strdup(pReq->m_szParam); + pReq->dataLength = pReq->m_szParam.GetLength(); + } + } + + if (pReq->m_bMainSite) { + pReq->flags |= NLHRF_PERSISTENT; + pReq->nlc = m_hAPIConnection; + pReq->AddHeader("Cookie", m_szCookie); + } + + bool bRetryable = pReq->nlc != nullptr; + debugLogA("Executing request #%d:\n%s", pReq->m_iReqNum, pReq->szUrl); + +LBL_Retry: + NLHR_PTR reply(Netlib_HttpTransaction(m_hNetlibUser, pReq)); + if (reply == nullptr) { + debugLogA("Request %d failed", pReq->m_iReqNum); + + if (pReq->m_bMainSite) { + if (IsStatusConnecting(m_iStatus)) + ConnectionFailed(LOGINERR_NONETWORK); + m_hAPIConnection = nullptr; + } + + if (bRetryable) { + debugLogA("Attempt to retry request #%d", pReq->m_iReqNum); + pReq->nlc = nullptr; + bRetryable = false; + goto LBL_Retry; + } + } + else { + if (pReq->m_pFunc != nullptr) + (this->*(pReq->m_pFunc))(reply, pReq); + + if (pReq->m_bMainSite) + m_hAPIConnection = reply->nlc; + } + delete pReq; +} + +void CDiscordProto::OnLoggedIn() +{ + debugLogA("CDiscordProto::OnLoggedIn"); + m_bOnline = true; + SetServerStatus(m_iDesiredStatus); +} + +void CDiscordProto::OnLoggedOut() +{ + debugLogA("CDiscordProto::OnLoggedOut"); + m_bOnline = false; + m_bTerminated = true; + m_iGatewaySeq = 0; + m_szTempToken = nullptr; + m_szCookie.Empty(); + m_szWSCookie.Empty(); + + m_impl.m_heartBeat.StopSafe(); + + ProtoBroadcastAck(0, ACKTYPE_STATUS, ACKRESULT_SUCCESS, (HANDLE)m_iStatus, ID_STATUS_OFFLINE); + m_iStatus = m_iDesiredStatus = ID_STATUS_OFFLINE; + + setAllContactStatuses(ID_STATUS_OFFLINE, false); +} + +void CDiscordProto::ShutdownSession() +{ + if (m_bTerminated) + return; + + debugLogA("CDiscordProto::ShutdownSession"); + + // shutdown all resources + if (m_hWorkerThread) + SetEvent(m_evRequestsQueue); + if (m_hGatewayConnection) + Netlib_Shutdown(m_hGatewayConnection); + if (m_hAPIConnection) + Netlib_Shutdown(m_hAPIConnection); + + OnLoggedOut(); +} + +void CDiscordProto::ConnectionFailed(int iReason) +{ + debugLogA("CDiscordProto::ConnectionFailed -> reason %d", iReason); + delSetting("AccessToken"); + + ProtoBroadcastAck(0, ACKTYPE_LOGIN, ACKRESULT_FAILED, nullptr, iReason); + ShutdownSession(); +} diff --git a/protocols/Discord/src/dispatch.cpp b/protocols/Discord/src/dispatch.cpp new file mode 100644 index 0000000000..5d79feb9fe --- /dev/null +++ b/protocols/Discord/src/dispatch.cpp @@ -0,0 +1,592 @@ +/* +Copyright © 2016-22 Miranda NG team + +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, see <http://www.gnu.org/licenses/>. +*/ + +#include "stdafx.h" + +#pragma pack(4) + +///////////////////////////////////////////////////////////////////////////////////////// + +struct CDiscordCommand +{ + const wchar_t *szCommandId; + GatewayHandlerFunc pFunc; +} +static handlers[] = // these structures must me sorted alphabetically +{ + { L"CALL_CREATE", &CDiscordProto::OnCommandCallCreated }, + { L"CALL_DELETE", &CDiscordProto::OnCommandCallDeleted }, + { L"CALL_UPDATE", &CDiscordProto::OnCommandCallUpdated }, + + { L"CHANNEL_CREATE", &CDiscordProto::OnCommandChannelCreated }, + { L"CHANNEL_DELETE", &CDiscordProto::OnCommandChannelDeleted }, + { L"CHANNEL_UPDATE", &CDiscordProto::OnCommandChannelUpdated }, + + { L"GUILD_CREATE", &CDiscordProto::OnCommandGuildCreated }, + { L"GUILD_DELETE", &CDiscordProto::OnCommandGuildDeleted }, + { L"GUILD_MEMBER_ADD", &CDiscordProto::OnCommandGuildMemberAdded }, + { L"GUILD_MEMBER_LIST_UPDATE", &CDiscordProto::OnCommandGuildMemberListUpdate }, + { L"GUILD_MEMBER_REMOVE", &CDiscordProto::OnCommandGuildMemberRemoved }, + { L"GUILD_MEMBER_UPDATE", &CDiscordProto::OnCommandGuildMemberUpdated }, + { L"GUILD_ROLE_CREATE", &CDiscordProto::OnCommandRoleCreated }, + { L"GUILD_ROLE_DELETE", &CDiscordProto::OnCommandRoleDeleted }, + { L"GUILD_ROLE_UPDATE", &CDiscordProto::OnCommandRoleCreated }, + + { L"MESSAGE_ACK", &CDiscordProto::OnCommandMessageAck }, + { L"MESSAGE_CREATE", &CDiscordProto::OnCommandMessageCreate }, + { L"MESSAGE_DELETE", &CDiscordProto::OnCommandMessageDelete }, + { L"MESSAGE_UPDATE", &CDiscordProto::OnCommandMessageUpdate }, + + { L"PRESENCE_UPDATE", &CDiscordProto::OnCommandPresence }, + + { L"READY", &CDiscordProto::OnCommandReady }, + + { L"RELATIONSHIP_ADD", &CDiscordProto::OnCommandFriendAdded }, + { L"RELATIONSHIP_REMOVE", &CDiscordProto::OnCommandFriendRemoved }, + + { L"TYPING_START", &CDiscordProto::OnCommandTyping }, + + { L"USER_SETTINGS_UPDATE", &CDiscordProto::OnCommandUserSettingsUpdate }, + { L"USER_UPDATE", &CDiscordProto::OnCommandUserUpdate }, +}; + +static int __cdecl pSearchFunc(const void *p1, const void *p2) +{ + return wcscmp(((CDiscordCommand*)p1)->szCommandId, ((CDiscordCommand*)p2)->szCommandId); +} + +GatewayHandlerFunc CDiscordProto::GetHandler(const wchar_t *pwszCommand) +{ + CDiscordCommand tmp = { pwszCommand, nullptr }; + CDiscordCommand *p = (CDiscordCommand*)bsearch(&tmp, handlers, _countof(handlers), sizeof(handlers[0]), pSearchFunc); + return (p != nullptr) ? p->pFunc : nullptr; +} + +///////////////////////////////////////////////////////////////////////////////////////// +// channel operations + +void CDiscordProto::OnCommandChannelCreated(const JSONNode &pRoot) +{ + SnowFlake guildId = ::getId(pRoot["guild_id"]); + if (guildId == 0) + PreparePrivateChannel(pRoot); + else { + // group channel for a guild + CDiscordGuild *pGuild = FindGuild(guildId); + if (pGuild && m_bUseGroupchats) { + CDiscordUser *pUser = ProcessGuildChannel(pGuild, pRoot); + if (pUser) + CreateChat(pGuild, pUser); + } + } +} + +void CDiscordProto::OnCommandChannelDeleted(const JSONNode &pRoot) +{ + CDiscordUser *pUser = FindUserByChannel(::getId(pRoot["id"])); + if (pUser == nullptr) + return; + + SnowFlake guildId = ::getId(pRoot["guild_id"]); + if (guildId == 0) { + pUser->channelId = pUser->lastMsgId = 0; + delSetting(pUser->hContact, DB_KEY_CHANNELID); + } + else { + CDiscordGuild *pGuild = FindGuild(guildId); + if (pGuild != nullptr) + Chat_Terminate(m_szModuleName, pUser->wszUsername, true); + } +} + +void CDiscordProto::OnCommandChannelUpdated(const JSONNode &pRoot) +{ + CDiscordUser *pUser = FindUserByChannel(::getId(pRoot["id"])); + if (pUser == nullptr) + return; + + pUser->lastMsgId = ::getId(pRoot["last_message_id"]); + + SnowFlake guildId = ::getId(pRoot["guild_id"]); + if (guildId != 0) { + CDiscordGuild *pGuild = FindGuild(guildId); + if (pGuild == nullptr) + return; + + CMStringW wszName = pRoot["name"].as_mstring(); + if (!wszName.IsEmpty()) { + CMStringW wszNewName = pGuild->wszName + L"#" + wszName; + Chat_ChangeSessionName(m_szModuleName, pUser->wszUsername, wszNewName); + } + + CMStringW wszTopic = pRoot["topic"].as_mstring(); + Chat_SetStatusbarText(m_szModuleName, pUser->wszUsername, wszTopic); + + GCEVENT gce = { m_szModuleName, 0, GC_EVENT_TOPIC }; + gce.pszID.w = pUser->wszUsername; + gce.pszText.w = wszTopic; + gce.time = time(0); + Chat_Event(&gce); + } +} + +///////////////////////////////////////////////////////////////////////////////////////// +// reading a new message + +void CDiscordProto::OnCommandFriendAdded(const JSONNode &pRoot) +{ + CDiscordUser *pUser = PrepareUser(pRoot["user"]); + pUser->bIsPrivate = true; + ProcessType(pUser, pRoot); +} + +void CDiscordProto::OnCommandFriendRemoved(const JSONNode &pRoot) +{ + SnowFlake id = ::getId(pRoot["id"]); + CDiscordUser *pUser = FindUser(id); + if (pUser != nullptr) { + if (pUser->hContact) + if (pUser->bIsPrivate) + db_delete_contact(pUser->hContact); + + arUsers.remove(pUser); + } +} + +///////////////////////////////////////////////////////////////////////////////////////// +// guild synchronization + +void CDiscordProto::OnCommandGuildCreated(const JSONNode &pRoot) +{ + if (m_bUseGroupchats) + ProcessGuild(pRoot); +} + +void CDiscordProto::OnCommandGuildDeleted(const JSONNode &pRoot) +{ + CDiscordGuild *pGuild = FindGuild(::getId(pRoot["id"])); + if (pGuild == nullptr) + return; + + for (auto &it : arUsers.rev_iter()) + if (it->pGuild == pGuild) { + Chat_Terminate(m_szModuleName, it->wszUsername, true); + arUsers.removeItem(&it); + } + + Chat_Terminate(m_szModuleName, pRoot["name"].as_mstring(), true); + + arGuilds.remove(pGuild); +} + +///////////////////////////////////////////////////////////////////////////////////////// +// guild members + +void CDiscordProto::OnCommandGuildMemberAdded(const JSONNode&) +{ +} + +void CDiscordProto::OnCommandGuildMemberListUpdate(const JSONNode &pRoot) +{ + auto *pGuild = FindGuild(::getId(pRoot["guild_id"])); + if (pGuild == nullptr) + return; + + int iStatus = 0; + + for (auto &ops: pRoot["ops"]) { + for (auto &it : ops["items"]) { + auto &item = it.at((size_t)0); + if (!mir_strcmp(item .name(), "group")) { + iStatus = item ["id"].as_string() == "online" ? ID_STATUS_ONLINE : ID_STATUS_OFFLINE; + continue; + } + + if (!mir_strcmp(item .name(), "member")) { + bool bNew = false; + auto *pm = ProcessGuildUser(pGuild, item, &bNew); + pm->iStatus = iStatus; + + if (bNew) + AddGuildUser(pGuild, *pm); + else if (iStatus) { + CMStringW wszUserId(FORMAT, L"%lld", pm->userId); + + GCEVENT gce = { m_szModuleName, 0, GC_EVENT_SETCONTACTSTATUS }; + gce.time = time(0); + gce.pszUID.w = wszUserId; + + for (auto &cc : pGuild->arChannels) { + if (!cc->bIsGroup) + continue; + + gce.pszID.w = cc->wszChannelName; + gce.dwItemData = iStatus; + Chat_Event(&gce); + } + } + } + } + } + + pGuild->bSynced = true; +} + +void CDiscordProto::OnCommandGuildMemberRemoved(const JSONNode &pRoot) +{ + CDiscordGuild *pGuild = FindGuild(::getId(pRoot["guild_id"])); + if (pGuild == nullptr) + return; + + CMStringW wszUserId = pRoot["user"]["id"].as_mstring(); + + for (auto &pUser : arUsers) { + if (pUser->pGuild != pGuild) + continue; + + GCEVENT gce = { m_szModuleName, 0, GC_EVENT_PART }; + gce.pszUID.w = pUser->wszUsername; + gce.time = time(0); + gce.pszUID.w = wszUserId; + Chat_Event(&gce); + } +} + +void CDiscordProto::OnCommandGuildMemberUpdated(const JSONNode &pRoot) +{ + CDiscordGuild *pGuild = FindGuild(::getId(pRoot["guild_id"])); + if (pGuild == nullptr) + return; + + CMStringW wszUserId = pRoot["user"]["id"].as_mstring(); + CDiscordGuildMember *gm = pGuild->FindUser(_wtoi64(wszUserId)); + if (gm == nullptr) + return; + + gm->wszDiscordId = pRoot["user"]["username"].as_mstring() + L"#" + pRoot["user"]["discriminator"].as_mstring(); + gm->wszNick = pRoot["nick"].as_mstring(); + if (gm->wszNick.IsEmpty()) + gm->wszNick = pRoot["user"]["username"].as_mstring(); + + for (auto &it : arUsers) { + if (it->pGuild != pGuild) + continue; + + CMStringW wszOldNick; + SESSION_INFO *si = g_chatApi.SM_FindSession(it->wszUsername, m_szModuleName); + if (si != nullptr) { + USERINFO *ui = g_chatApi.UM_FindUser(si, wszUserId); + if (ui != nullptr) + wszOldNick = ui->pszNick; + } + + GCEVENT gce = { m_szModuleName, 0, GC_EVENT_NICK }; + gce.pszID.w = it->wszUsername; + gce.time = time(0); + gce.pszUID.w = wszUserId; + gce.pszNick.w = wszOldNick; + gce.pszText.w = gm->wszNick; + Chat_Event(&gce); + } +} + +///////////////////////////////////////////////////////////////////////////////////////// +// roles + +void CDiscordProto::OnCommandRoleCreated(const JSONNode &pRoot) +{ + CDiscordGuild *pGuild = FindGuild(::getId(pRoot["guild_id"])); + if (pGuild != nullptr) + ProcessRole(pGuild, pRoot["role"]); +} + +void CDiscordProto::OnCommandRoleDeleted(const JSONNode &pRoot) +{ + CDiscordGuild *pGuild = FindGuild(::getId(pRoot["guild_id"])); + if (pGuild == nullptr) + return; + + SnowFlake id = ::getId(pRoot["role_id"]); + CDiscordRole *pRole = pGuild->arRoles.find((CDiscordRole*)&id); + if (pRole == nullptr) + return; + + int iOldPosition = pRole->position; + pGuild->arRoles.remove(pRole); + + for (auto &it : pGuild->arRoles) + if (it->position > iOldPosition) + it->position--; + + for (auto &it : arUsers) { + if (it->pGuild != pGuild) + continue; + + SESSION_INFO *si = g_chatApi.SM_FindSession(it->wszUsername, m_szModuleName); + if (si != nullptr) { + g_chatApi.TM_RemoveAll(&si->pStatuses); + BuildStatusList(pGuild, si); + } + } +} + +///////////////////////////////////////////////////////////////////////////////////////// +// reading a new message + +void CDiscordProto::OnCommandMessageCreate(const JSONNode &pRoot) +{ + OnCommandMessage(pRoot, true); +} + +void CDiscordProto::OnCommandMessageUpdate(const JSONNode &pRoot) +{ + OnCommandMessage(pRoot, false); +} + +void CDiscordProto::OnCommandMessage(const JSONNode &pRoot, bool bIsNew) +{ + CMStringW wszMessageId = pRoot["id"].as_mstring(); + CMStringW wszUserId = pRoot["author"]["id"].as_mstring(); + SnowFlake userId = _wtoi64(wszUserId); + SnowFlake msgId = _wtoi64(wszMessageId); + + // try to find a sender by his channel + SnowFlake channelId = ::getId(pRoot["channel_id"]); + CDiscordUser *pUser = FindUserByChannel(channelId); + if (pUser == nullptr) { + debugLogA("skipping message with unknown channel id=%lld", channelId); + return; + } + + char szMsgId[100]; + _i64toa_s(msgId, szMsgId, _countof(szMsgId), 10); + + COwnMessage ownMsg(::getId(pRoot["nonce"]), 0); + COwnMessage *p = arOwnMessages.find(&ownMsg); + if (p != nullptr) { // own message? skip it + ProtoBroadcastAck(pUser->hContact, ACKTYPE_MESSAGE, ACKRESULT_SUCCESS, (HANDLE)p->reqId, (LPARAM)szMsgId); + debugLogA("skipping own message with nonce=%lld, id=%lld", ownMsg.nonce, msgId); + } + else { + CMStringW wszText = PrepareMessageText(pRoot); + if (wszText.IsEmpty()) + return; + + // old message? try to restore it from database + bool bOurMessage = userId == m_ownId; + if (!bIsNew) { + MEVENT hOldEvent = db_event_getById(m_szModuleName, szMsgId); + if (hOldEvent) { + DB::EventInfo dbei; + dbei.cbBlob = -1; + if (!db_event_get(hOldEvent, &dbei)) { + ptrW wszOldText(DbEvent_GetTextW(&dbei, CP_UTF8)); + if (wszOldText) + wszText.Insert(0, wszOldText); + if (dbei.flags & DBEF_SENT) + bOurMessage = true; + } + } + } + + const JSONNode &edited = pRoot["edited_timestamp"]; + if (!edited.isnull()) + wszText.AppendFormat(L" (%s %s)", TranslateT("edited at"), edited.as_mstring().c_str()); + + if (pUser->bIsPrivate && !pUser->bIsGroup) { + // if a message has myself as an author, add some flags + PROTORECVEVENT recv = {}; + if (bOurMessage) + recv.flags = PREF_CREATEREAD | PREF_SENT; + + debugLogA("store a message from private user %lld, channel id %lld", pUser->id, pUser->channelId); + ptrA buf(mir_utf8encodeW(wszText)); + + recv.timestamp = (uint32_t)StringToDate(pRoot["timestamp"].as_mstring()); + recv.szMessage = buf; + recv.szMsgId = szMsgId; + ProtoChainRecvMsg(pUser->hContact, &recv); + } + else { + debugLogA("store a message into the group channel id %lld", channelId); + + SESSION_INFO *si = g_chatApi.SM_FindSession(pUser->wszUsername, m_szModuleName); + if (si == nullptr) { + debugLogA("message to unknown channel %lld ignored", channelId); + return; + } + + ProcessChatUser(pUser, wszUserId, pRoot); + + ParseSpecialChars(si, wszText); + wszText.Replace(L"%", L"%%"); + + GCEVENT gce = { m_szModuleName, 0, GC_EVENT_MESSAGE }; + gce.pszID.w = pUser->wszUsername; + gce.dwFlags = GCEF_ADDTOLOG; + gce.pszUID.w = wszUserId; + gce.pszText.w = wszText; + gce.time = (uint32_t)StringToDate(pRoot["timestamp"].as_mstring()); + gce.bIsMe = bOurMessage; + Chat_Event(&gce); + + debugLogW(L"New channel %s message from %s: %s", si->ptszID, gce.pszUID.w, gce.pszText.w); + } + } + + pUser->lastMsgId = msgId; + + SnowFlake lastId = getId(pUser->hContact, DB_KEY_LASTMSGID); // as stored in a database + if (lastId < msgId) + setId(pUser->hContact, DB_KEY_LASTMSGID, msgId); +} + +///////////////////////////////////////////////////////////////////////////////////////// +// someone changed its status + +void CDiscordProto::OnCommandMessageAck(const JSONNode &pRoot) +{ + CDiscordUser *pUser = FindUserByChannel(pRoot["channel_id"]); + if (pUser != nullptr) + pUser->lastMsgId = ::getId(pRoot["message_id"]); +} + +///////////////////////////////////////////////////////////////////////////////////////// +// message deleted + +void CDiscordProto::OnCommandMessageDelete(const JSONNode &pRoot) +{ + if (!m_bSyncDeleteMsgs) + return; + + CMStringA msgid(pRoot["id"].as_mstring()); + if (!msgid.IsEmpty()) { + MEVENT hEvent = db_event_getById(m_szModuleName, msgid); + if (hEvent) + db_event_delete(hEvent); + } +} + +///////////////////////////////////////////////////////////////////////////////////////// +// someone changed its status + +void CDiscordProto::OnCommandPresence(const JSONNode &pRoot) +{ + auto *pGuild = FindGuild(::getId(pRoot["user"]["guild_id"])); + if (pGuild == nullptr) + ProcessPresence(pRoot); + // else + // pGuild->ProcessPresence(pRoot); +} + +///////////////////////////////////////////////////////////////////////////////////////// +// gateway session start + +void CDiscordProto::OnCommandReady(const JSONNode &pRoot) +{ + OnLoggedIn(); + + GatewaySendHeartbeat(); + m_impl.m_heartBeat.StartSafe(m_iHartbeatInterval); + + m_szGatewaySessionId = pRoot["session_id"].as_mstring(); + + if (m_bUseGroupchats) + for (auto &it : pRoot["guilds"]) + ProcessGuild(it); + + for (auto &it : pRoot["relationships"]) { + CDiscordUser *pUser = PrepareUser(it["user"]); + ProcessType(pUser, it); + } + + for (auto &it : pRoot["presences"]) + ProcessPresence(it); + + for (auto &it : pRoot["private_channels"]) + PreparePrivateChannel(it); + + for (auto &it : pRoot["read_state"]) { + CDiscordUser *pUser = FindUserByChannel(::getId(it["id"])); + if (pUser != nullptr) + pUser->lastReadId = ::getId(it["last_message_id"]); + } +} + +///////////////////////////////////////////////////////////////////////////////////////// +// UTN support + +void CDiscordProto::OnCommandTyping(const JSONNode &pRoot) +{ + SnowFlake channelId = ::getId(pRoot["channel_id"]); + debugLogA("user typing notification: channelid=%lld", channelId); + + CDiscordUser *pChannel = FindUserByChannel(channelId); + if (pChannel == nullptr) { + debugLogA("channel with id=%lld is not found", channelId); + return; + } + + // both private groupchats & guild channels are chat rooms for Miranda + if (pChannel->pGuild) { + debugLogA("user is typing in a group channel"); + + CMStringW wszUerId = pRoot["user_id"].as_mstring(); + ProcessGuildUser(pChannel->pGuild, pRoot); // never returns null + + GCEVENT gce = { m_szModuleName, 0, GC_EVENT_TYPING }; + gce.pszID.w = pChannel->wszUsername; + gce.pszUID.w = wszUerId; + gce.dwItemData = 1; + gce.time = time(0); + Chat_Event(&gce); + } + else { + debugLogA("user is typing in his private channel"); + CallService(MS_PROTO_CONTACTISTYPING, pChannel->hContact, 20); + } +} + +///////////////////////////////////////////////////////////////////////////////////////// +// User info update + +void CDiscordProto::OnCommandUserUpdate(const JSONNode &pRoot) +{ + SnowFlake id = ::getId(pRoot["id"]); + + MCONTACT hContact; + if (id != m_ownId) { + CDiscordUser *pUser = FindUser(id); + if (pUser == nullptr) + return; + + hContact = pUser->hContact; + } + else hContact = 0; + + // force rereading avatar + CheckAvatarChange(hContact, pRoot["avatar"].as_mstring()); +} + +void CDiscordProto::OnCommandUserSettingsUpdate(const JSONNode &pRoot) +{ + int iStatus = StrToStatus(pRoot["status"].as_mstring()); + if (iStatus != 0) { + int iOldStatus = m_iStatus; m_iStatus = iStatus; + ProtoBroadcastAck(0, ACKTYPE_STATUS, ACKRESULT_SUCCESS, (HANDLE)iOldStatus, m_iStatus); + } +} diff --git a/protocols/Discord/src/gateway.cpp b/protocols/Discord/src/gateway.cpp new file mode 100644 index 0000000000..82c3b70eb5 --- /dev/null +++ b/protocols/Discord/src/gateway.cpp @@ -0,0 +1,346 @@ +/* +Copyright © 2016-22 Miranda NG team + +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, see <http://www.gnu.org/licenses/>. +*/ + +#include "stdafx.h" + +////////////////////////////////////////////////////////////////////////////////////// +// sends a piece of JSON to a server via a websocket, masked + +bool CDiscordProto::GatewaySend(const JSONNode &pRoot) +{ + if (m_hGatewayConnection == nullptr) + return false; + + json_string szText = pRoot.write(); + debugLogA("Gateway send: %s", szText.c_str()); + WebSocket_SendText(m_hGatewayConnection, szText.c_str()); + return true; +} + +////////////////////////////////////////////////////////////////////////////////////// +// gateway worker thread + +void CDiscordProto::GatewayThread(void*) +{ + while (GatewayThreadWorker()) + ; + ShutdownSession(); +} + +bool CDiscordProto::GatewayThreadWorker() +{ + NETLIBHTTPHEADER hdrs[] = + { + { "Origin", "https://discord.com" }, + { 0, 0 }, + { 0, 0 }, + }; + + if (!m_szWSCookie.IsEmpty()) { + hdrs[1].szName = "Cookie"; + hdrs[1].szValue = m_szWSCookie.GetBuffer(); + } + + NLHR_PTR pReply(WebSocket_Connect(m_hGatewayNetlibUser, m_szGateway + "/?encoding=json&v=8", hdrs)); + if (pReply == nullptr) { + debugLogA("Gateway connection failed, exiting"); + return false; + } + + if (auto *pszNewCookie = Netlib_GetHeader(pReply, "Set-Cookie")) { + char *p = strchr(pszNewCookie, ';'); + if (p) *p = 0; + + m_szWSCookie = pszNewCookie; + } + + if (pReply->resultCode != 101) { + // if there's no cookie & Miranda is bounced with error 404, simply apply the cookie and try again + if (pReply->resultCode == 404) { + if (hdrs[1].szName == nullptr) + return true; + + m_szWSCookie.Empty(); // don't use the same cookie twice + } + return false; + } + + // succeeded! + debugLogA("Gateway connection succeeded"); + m_hGatewayConnection = pReply->nlc; + + bool bExit = false; + int offset = 0; + MBinBuffer netbuf; + + while (!bExit) { + if (m_bTerminated) + break; + + unsigned char buf[2048]; + int bufSize = Netlib_Recv(m_hGatewayConnection, (char*)buf + offset, _countof(buf) - offset, MSG_NODUMP); + if (bufSize == 0) { + debugLogA("Gateway connection gracefully closed"); + bExit = !m_bTerminated; + break; + } + if (bufSize < 0) { + debugLogA("Gateway connection error, exiting"); + break; + } + + WSHeader hdr; + if (!WebSocket_InitHeader(hdr, buf, bufSize)) { + offset += bufSize; + continue; + } + offset = 0; + + debugLogA("Got packet: buffer = %d, opcode = %d, headerSize = %d, final = %d, masked = %d", bufSize, hdr.opCode, hdr.headerSize, hdr.bIsFinal, hdr.bIsMasked); + + // we have some additional data, not only opcode + if ((size_t)bufSize > hdr.headerSize) { + size_t currPacketSize = bufSize - hdr.headerSize; + netbuf.append(buf, bufSize); + while (currPacketSize < hdr.payloadSize) { + int result = Netlib_Recv(m_hGatewayConnection, (char*)buf, _countof(buf), MSG_NODUMP); + if (result == 0) { + debugLogA("Gateway connection gracefully closed"); + bExit = !m_bTerminated; + break; + } + if (result < 0) { + debugLogA("Gateway connection error, exiting"); + break; + } + currPacketSize += result; + netbuf.append(buf, result); + } + } + + // read all payloads from the current buffer, one by one + size_t prevSize = 0; + while (true) { + switch (hdr.opCode) { + case 0: // text packet + case 1: // binary packet + case 2: // continuation + if (hdr.bIsFinal) { + // process a packet here + CMStringA szJson((char*)netbuf.data() + hdr.headerSize, (int)hdr.payloadSize); + debugLogA("JSON received:\n%s", szJson.c_str()); + JSONNode root = JSONNode::parse(szJson); + if (root) + bExit = GatewayProcess(root); + } + break; + + case 8: // close + debugLogA("server required to exit"); + bExit = true; // simply reconnect, don't exit + break; + + case 9: // ping + debugLogA("ping received"); + Netlib_Send(m_hGatewayConnection, (char*)buf + hdr.headerSize, bufSize - int(hdr.headerSize), 0); + break; + } + + if (hdr.bIsFinal) + netbuf.remove(hdr.headerSize + hdr.payloadSize); + + if (netbuf.length() == 0) + break; + + // if we have not enough data for header, continue reading + if (!WebSocket_InitHeader(hdr, netbuf.data(), netbuf.length())) + break; + + // if we have not enough data for data, continue reading + if (hdr.headerSize + hdr.payloadSize > netbuf.length()) + break; + + debugLogA("Got inner packet: buffer = %d, opcode = %d, headerSize = %d, payloadSize = %d, final = %d, masked = %d", netbuf.length(), hdr.opCode, hdr.headerSize, hdr.payloadSize, hdr.bIsFinal, hdr.bIsMasked); + if (prevSize == netbuf.length()) { + netbuf.remove(prevSize); + debugLogA("dropping current packet, exiting"); + break; + } + + prevSize = netbuf.length(); + } + } + + Netlib_CloseHandle(m_hGatewayConnection); + m_hGatewayConnection = nullptr; + return bExit; +} + +////////////////////////////////////////////////////////////////////////////////////// +// handles server commands + +bool CDiscordProto::GatewayProcess(const JSONNode &pRoot) +{ + int opCode = pRoot["op"].as_int(); + switch (opCode) { + case OPCODE_DISPATCH: // process incoming command + { + int iSeq = pRoot["s"].as_int(); + if (iSeq != 0) + m_iGatewaySeq = iSeq; + + CMStringW wszCommand = pRoot["t"].as_mstring(); + debugLogA("got a server command to dispatch: %S", wszCommand.c_str()); + + GatewayHandlerFunc pFunc = GetHandler(wszCommand); + if (pFunc) + (this->*pFunc)(pRoot["d"]); + } + break; + + case OPCODE_RECONNECT: // we need to reconnect asap + debugLogA("we need to reconnect, leaving worker thread"); + return true; + + case OPCODE_INVALID_SESSION: // session invalidated + if (pRoot["d"].as_bool()) // session can be resumed + GatewaySendResume(); + else { + Sleep(5000); // 5 seconds - recommended timeout + GatewaySendIdentify(); + } + break; + + case OPCODE_HELLO: // hello + m_iHartbeatInterval = pRoot["d"]["heartbeat_interval"].as_int(); + + GatewaySendIdentify(); + break; + + case OPCODE_HEARTBEAT_ACK: // heartbeat ack + break; + + default: + debugLogA("ACHTUNG! Unknown opcode: %d, report it to developer", opCode); + } + + return false; +} + +////////////////////////////////////////////////////////////////////////////////////// +// requests to be sent to a gateway + +void CDiscordProto::GatewaySendGuildInfo(CDiscordGuild *pGuild) +{ + if (!pGuild->arChannels.getCount()) + return; + + JSONNode a1(JSON_ARRAY); a1 << INT_PARAM("", 0) << INT_PARAM("", 99); + + CMStringA szId(FORMAT, "%lld", pGuild->arChannels[0]->id); + JSONNode chl(JSON_ARRAY); chl.set_name(szId.c_str()); chl << a1; + + JSONNode channels; channels.set_name("channels"); channels << chl; + + JSONNode payload; payload.set_name("d"); + payload << SINT64_PARAM("guild_id", pGuild->id) << BOOL_PARAM("typing", true) << BOOL_PARAM("activities", true) << BOOL_PARAM("presences", true) << channels; + + JSONNode root; + root << INT_PARAM("op", OPCODE_REQUEST_SYNC_CHANNEL) << payload; + GatewaySend(root); +} + +void CDiscordProto::GatewaySendHeartbeat() +{ + // we don't send heartbeat packets until we get logged in + if (!m_iHartbeatInterval || !m_iGatewaySeq) + return; + + JSONNode root; + root << INT_PARAM("op", OPCODE_HEARTBEAT) << INT_PARAM("d", m_iGatewaySeq); + GatewaySend(root); +} + +void CDiscordProto::GatewaySendIdentify() +{ + if (m_szAccessToken == nullptr) { + ConnectionFailed(LOGINERR_WRONGPASSWORD); + return; + } + + char szOs[256]; + OS_GetDisplayString(szOs, _countof(szOs)); + + char szVersion[256]; + Miranda_GetVersionText(szVersion, _countof(szVersion)); + + JSONNode props; props.set_name("properties"); + props << CHAR_PARAM("os", szOs) << CHAR_PARAM("browser", "Chrome") << CHAR_PARAM("device", szVersion) + << CHAR_PARAM("referrer", "https://miranda-ng.org") << CHAR_PARAM("referring_domain", "miranda-ng.org"); + + JSONNode payload; payload.set_name("d"); + payload << CHAR_PARAM("token", m_szAccessToken) << props << BOOL_PARAM("compress", false) << INT_PARAM("large_threshold", 250); + + JSONNode root; + root << INT_PARAM("op", OPCODE_IDENTIFY) << payload; + GatewaySend(root); +} + +void CDiscordProto::GatewaySendResume() +{ + char szRandom[40]; + uint8_t random[16]; + Utils_GetRandom(random, _countof(random)); + bin2hex(random, _countof(random), szRandom); + + JSONNode root; + root << CHAR_PARAM("token", szRandom) << CHAR_PARAM("session_id", m_szGatewaySessionId) << INT_PARAM("seq", m_iGatewaySeq); + GatewaySend(root); +} + +bool CDiscordProto::GatewaySendStatus(int iStatus, const wchar_t *pwszStatusText) +{ + if (iStatus == ID_STATUS_OFFLINE) { + Push(new AsyncHttpRequest(this, REQUEST_POST, "/auth/logout", nullptr)); + return true; + } + + const char *pszStatus; + switch (iStatus) { + case ID_STATUS_AWAY: + case ID_STATUS_NA: + pszStatus = "idle"; break; + case ID_STATUS_DND: + pszStatus = "dnd"; break; + case ID_STATUS_INVISIBLE: + pszStatus = "invisible"; break; + default: + pszStatus = "online"; break; + } + + JSONNode payload; payload.set_name("d"); + payload << INT64_PARAM("since", __int64(time(0)) * 1000) << BOOL_PARAM("afk", true) << CHAR_PARAM("status", pszStatus); + if (pwszStatusText == nullptr) + payload << CHAR_PARAM("game", nullptr); + else { + JSONNode game; game.set_name("game"); game << WCHAR_PARAM("name", pwszStatusText) << INT_PARAM("type", 0); + payload << game; + } + + JSONNode root; root << INT_PARAM("op", OPCODE_STATUS_UPDATE) << payload; + return GatewaySend(root); +} diff --git a/protocols/Discord/src/groupchat.cpp b/protocols/Discord/src/groupchat.cpp new file mode 100644 index 0000000000..f34e35c93a --- /dev/null +++ b/protocols/Discord/src/groupchat.cpp @@ -0,0 +1,235 @@ +/* +Copyright © 2016-22 Miranda NG team + +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, see <http://www.gnu.org/licenses/>. +*/ + +#include "stdafx.h" + +enum { + IDM_CANCEL, + IDM_COPY_ID, + + IDM_CHANGENICK, IDM_CHANGETOPIC, IDM_RENAME, IDM_DESTROY +}; + +///////////////////////////////////////////////////////////////////////////////////////// + +void BuildStatusList(const CDiscordGuild *pGuild, SESSION_INFO *si) +{ + Chat_AddGroup(si, L"@owner"); + + for (auto &it : pGuild->arRoles) + Chat_AddGroup(si, it->wszName); +} + +///////////////////////////////////////////////////////////////////////////////////////// + +static gc_item sttLogListItems[] = +{ + { LPGENW("Change &nickname"), IDM_CHANGENICK, MENU_ITEM }, + { LPGENW("Channel control"), FALSE, MENU_NEWPOPUP }, + { LPGENW("Change &topic"), IDM_CHANGETOPIC, MENU_POPUPITEM }, + { LPGENW("&Rename channel"), IDM_RENAME, MENU_POPUPITEM }, + { nullptr, 0, MENU_POPUPSEPARATOR }, + { LPGENW("&Destroy channel"), IDM_DESTROY, MENU_POPUPITEM }, +}; + +static gc_item sttNicklistItems[] = +{ + { LPGENW("Copy ID"), IDM_COPY_ID, MENU_ITEM }, +}; + +int CDiscordProto::GroupchatMenuHook(WPARAM, LPARAM lParam) +{ + GCMENUITEMS* gcmi = (GCMENUITEMS*)lParam; + if (gcmi == nullptr) + return 0; + + if (mir_strcmpi(gcmi->pszModule, m_szModuleName)) + return 0; + + CDiscordUser *pChat = FindUserByChannel(_wtoi64(gcmi->pszID)); + if (pChat == nullptr) + return 0; + + if (gcmi->Type == MENU_ON_LOG) + Chat_AddMenuItems(gcmi->hMenu, _countof(sttLogListItems), sttLogListItems, &g_plugin); + else if (gcmi->Type == MENU_ON_NICKLIST) + Chat_AddMenuItems(gcmi->hMenu, _countof(sttNicklistItems), sttNicklistItems, &g_plugin); + + return 0; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +void CDiscordProto::Chat_SendPrivateMessage(GCHOOK *gch) +{ + SnowFlake userId = _wtoi64(gch->ptszUID); + + MCONTACT hContact; + CDiscordUser *pUser = FindUser(userId); + if (pUser == nullptr) { + PROTOSEARCHRESULT psr = { sizeof(psr) }; + psr.id.w = (wchar_t*)gch->ptszUID; + psr.nick.w = (wchar_t*)gch->ptszNick; + if ((hContact = AddToList(PALF_TEMPORARY, &psr)) == 0) + return; + + setId(hContact, DB_KEY_ID, userId); + setId(hContact, DB_KEY_CHANNELID, _wtoi64(gch->si->ptszID)); + setWString(hContact, DB_KEY_NICK, gch->ptszNick); + Contact::Hide(hContact); + db_set_dw(hContact, "Ignore", "Mask1", 0); + } + else hContact = pUser->hContact; + + CallService(MS_MSG_SENDMESSAGE, hContact, 0); +} + +void CDiscordProto::Chat_ProcessLogMenu(GCHOOK *gch) +{ + CDiscordUser *pUser = FindUserByChannel(_wtoi64(gch->si->ptszID)); + if (pUser == nullptr) + return; + + ENTER_STRING es = {}; + es.szModuleName = m_szModuleName; + + switch (gch->dwData) { + case IDM_DESTROY: + if (IDYES == MessageBox(nullptr, TranslateT("Do you really want to destroy this channel? This action is non-revertable."), m_tszUserName, MB_YESNO | MB_ICONQUESTION)) { + CMStringA szUrl(FORMAT, "/channels/%S", pUser->wszUsername.c_str()); + Push(new AsyncHttpRequest(this, REQUEST_DELETE, szUrl, nullptr)); + } + break; + + case IDM_RENAME: + es.caption = TranslateT("Enter new channel name:"); + es.type = ESF_COMBO; + es.szDataPrefix = "chat_rename"; + if (EnterString(&es)) { + JSONNode root; root << WCHAR_PARAM("name", es.ptszResult); + CMStringA szUrl(FORMAT, "/channels/%S", pUser->wszUsername.c_str()); + Push(new AsyncHttpRequest(this, REQUEST_PATCH, szUrl, nullptr, &root)); + mir_free(es.ptszResult); + } + break; + + case IDM_CHANGETOPIC: + es.caption = TranslateT("Enter new topic:"); + es.type = ESF_RICHEDIT; + es.szDataPrefix = "chat_topic"; + if (EnterString(&es)) { + JSONNode root; root << WCHAR_PARAM("topic", es.ptszResult); + CMStringA szUrl(FORMAT, "/channels/%S", pUser->wszUsername.c_str()); + Push(new AsyncHttpRequest(this, REQUEST_PATCH, szUrl, nullptr, &root)); + mir_free(es.ptszResult); + } + break; + + case IDM_CHANGENICK: + es.caption = TranslateT("Enter your new nick name:"); + es.type = ESF_COMBO; + es.szDataPrefix = "chat_nick"; + es.recentCount = 5; + if (EnterString(&es)) { + JSONNode root; root << WCHAR_PARAM("nick", es.ptszResult); + CMStringA szUrl(FORMAT, "/guilds/%lld/members/@me/nick", pUser->pGuild->id); + Push(new AsyncHttpRequest(this, REQUEST_PATCH, szUrl, nullptr, &root)); + mir_free(es.ptszResult); + } + break; + } +} + +void CDiscordProto::Chat_ProcessNickMenu(GCHOOK* gch) +{ + auto *pChannel = FindUserByChannel(_wtoi64(gch->si->ptszID)); + if (pChannel == nullptr || pChannel->pGuild == nullptr) + return; + + auto* pUser = pChannel->pGuild->FindUser(_wtoi64(gch->ptszUID)); + if (pUser == nullptr) + return; + + switch (gch->dwData) { + case IDM_COPY_ID: + CopyId(pUser->wszDiscordId); + break; + } +} + +int CDiscordProto::GroupchatEventHook(WPARAM, LPARAM lParam) +{ + GCHOOK *gch = (GCHOOK*)lParam; + if (gch == nullptr) + return 0; + + if (mir_strcmpi(gch->si->pszModule, m_szModuleName)) + return 0; + + switch (gch->iType) { + case GC_USER_MESSAGE: + if (m_bOnline && mir_wstrlen(gch->ptszText) > 0) { + CMStringW wszText(gch->ptszText); + wszText.TrimRight(); + + int pos = wszText.Find(':'); + if (pos != -1) { + auto wszWord = wszText.Left(pos); + wszWord.Trim(); + if (auto *si = g_chatApi.SM_FindSession(gch->si->ptszID, gch->si->pszModule)) { + USERINFO *pUser = nullptr; + + for (auto &U : si->getUserList()) + if (wszWord == U->pszNick) { + pUser = U; + break; + } + + if (pUser) { + wszText.Delete(0, pos); + wszText.Insert(0, L"<@" + CMStringW(pUser->pszUID) + L">"); + } + } + } + + Chat_UnescapeTags(wszText.GetBuffer()); + + JSONNode body; body << WCHAR_PARAM("content", wszText); + CMStringA szUrl(FORMAT, "/channels/%S/messages", gch->si->ptszID); + Push(new AsyncHttpRequest(this, REQUEST_POST, szUrl, nullptr, &body)); + } + break; + + case GC_USER_PRIVMESS: + Chat_SendPrivateMessage(gch); + break; + + case GC_USER_LOGMENU: + Chat_ProcessLogMenu(gch); + break; + + case GC_USER_NICKLISTMENU: + Chat_ProcessNickMenu(gch); + break; + + case GC_USER_TYPNOTIFY: + UserIsTyping(gch->si->hContact, (int)gch->dwData); + break; + } + + return 1; +} diff --git a/protocols/Discord/src/guilds.cpp b/protocols/Discord/src/guilds.cpp new file mode 100644 index 0000000000..d05ff80863 --- /dev/null +++ b/protocols/Discord/src/guilds.cpp @@ -0,0 +1,413 @@ +/* +Copyright © 2016-22 Miranda NG team + +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, see <http://www.gnu.org/licenses/>. +*/ + +#include "stdafx.h" + +int compareUsers(const CDiscordUser *p1, const CDiscordUser *p2); + +static int compareRoles(const CDiscordRole *p1, const CDiscordRole *p2) +{ + return compareInt64(p1->id, p2->id); +} + +static int compareChatUsers(const CDiscordGuildMember *p1, const CDiscordGuildMember *p2) +{ + return compareInt64(p1->userId, p2->userId); +} + +CDiscordGuild::CDiscordGuild(SnowFlake _id) : + id(_id), + arChannels(10, compareUsers), + arChatUsers(30, compareChatUsers), + arRoles(10, compareRoles) +{ +} + +CDiscordGuild::~CDiscordGuild() +{ +} + +CDiscordUser::~CDiscordUser() +{ + if (pGuild != nullptr) + pGuild->arChannels.remove(this); +} + +///////////////////////////////////////////////////////////////////////////////////////// +// reads a presence block from json + +void CDiscordProto::ProcessPresence(const JSONNode &root) +{ + auto userId = ::getId(root["user"]["id"]); + CDiscordUser *pUser = FindUser(userId); + if (pUser == nullptr) { + debugLogA("Presence from unknown user id %lld ignored", userId); + return; + } + + setWord(pUser->hContact, "Status", StrToStatus(root["status"].as_mstring())); + + CheckAvatarChange(pUser->hContact, root["user"]["avatar"].as_mstring()); + + for (auto &act : root["activities"]) { + CMStringW wszStatus(act["state"].as_mstring()); + if (!wszStatus.IsEmpty()) + db_set_ws(pUser->hContact, "CList", "StatusMsg", wszStatus); + } +} + +///////////////////////////////////////////////////////////////////////////////////////// +// reads a role from json + +void CDiscordProto::ProcessRole(CDiscordGuild *guild, const JSONNode &role) +{ + SnowFlake id = ::getId(role["id"]); + CDiscordRole *p = guild->arRoles.find((CDiscordRole*)&id); + if (p == nullptr) { + p = new CDiscordRole(); + p->id = id; + guild->arRoles.insert(p); + } + + p->color = role["color"].as_int(); + p->position = role["position"].as_int(); + p->permissions = role["permissions"].as_int(); + p->wszName = role["name"].as_mstring(); +} + +///////////////////////////////////////////////////////////////////////////////////////// + +static void sttSetGroupName(MCONTACT hContact, const wchar_t *pwszGroupName) +{ + ptrW wszOldName(Clist_GetGroup(hContact)); + if (wszOldName != nullptr) { + ptrW wszChatGroup(Chat_GetGroup()); + if (mir_wstrcmpi(wszOldName, wszChatGroup)) + return; // custom group, don't touch it + } + + Clist_SetGroup(hContact, pwszGroupName); +} + +void CDiscordProto::BatchChatCreate(void *param) +{ + CDiscordGuild *pGuild = (CDiscordGuild*)param; + + for (auto &it : pGuild->arChannels) + if (!it->bIsPrivate && !it->bIsGroup) + CreateChat(pGuild, it); +} + +void CDiscordProto::CreateChat(CDiscordGuild *pGuild, CDiscordUser *pUser) +{ + SESSION_INFO *si = Chat_NewSession(GCW_CHATROOM, m_szModuleName, pUser->wszUsername, pUser->wszChannelName); + si->pParent = pGuild->pParentSi; + pUser->hContact = si->hContact; + setId(pUser->hContact, DB_KEY_ID, pUser->channelId); + setId(pUser->hContact, DB_KEY_CHANNELID, pUser->channelId); + + SnowFlake oldMsgId = getId(pUser->hContact, DB_KEY_LASTMSGID); + if (oldMsgId == 0) + RetrieveHistory(pUser, MSG_BEFORE, pUser->lastMsgId, 20); + else if (!pUser->bSynced && pUser->lastMsgId > oldMsgId) { + pUser->bSynced = true; + RetrieveHistory(pUser, MSG_AFTER, oldMsgId, 99); + } + + if (m_bUseGuildGroups) { + if (pUser->parentId) { + CDiscordUser *pParent = FindUserByChannel(pUser->parentId); + if (pParent != nullptr) + sttSetGroupName(pUser->hContact, pParent->wszChannelName); + } + else sttSetGroupName(pUser->hContact, Clist_GroupGetName(pGuild->groupId)); + } + + BuildStatusList(pGuild, si); + + Chat_Control(m_szModuleName, pUser->wszUsername, m_bHideGroupchats ? WINDOW_HIDDEN : SESSION_INITDONE); + Chat_Control(m_szModuleName, pUser->wszUsername, SESSION_ONLINE); + + if (!pUser->wszTopic.IsEmpty()) { + Chat_SetStatusbarText(m_szModuleName, pUser->wszUsername, pUser->wszTopic); + + GCEVENT gce = { m_szModuleName, 0, GC_EVENT_TOPIC }; + gce.pszID.w = pUser->wszUsername; + gce.time = time(0); + gce.pszText.w = pUser->wszTopic; + Chat_Event(&gce); + } +} + +void CDiscordProto::ProcessGuild(const JSONNode &pRoot) +{ + SnowFlake guildId = ::getId(pRoot["id"]); + + CDiscordGuild *pGuild = FindGuild(guildId); + if (pGuild == nullptr) { + pGuild = new CDiscordGuild(guildId); + pGuild->LoadFromFile(); + arGuilds.insert(pGuild); + } + + pGuild->ownerId = ::getId(pRoot["owner_id"]); + pGuild->wszName = pRoot["name"].as_mstring(); + if (m_bUseGuildGroups) + pGuild->groupId = Clist_GroupCreate(Clist_GroupExists(m_wszDefaultGroup), pGuild->wszName); + + SESSION_INFO *si = Chat_NewSession(GCW_SERVER, m_szModuleName, pGuild->wszName, pGuild->wszName, pGuild); + if (si == nullptr) + return; + + pGuild->pParentSi = (SESSION_INFO*)si; + pGuild->hContact = si->hContact; + setId(pGuild->hContact, DB_KEY_CHANNELID, guildId); + + Chat_Control(m_szModuleName, pGuild->wszName, WINDOW_HIDDEN); + Chat_Control(m_szModuleName, pGuild->wszName, SESSION_ONLINE); + + for (auto &it : pRoot["roles"]) + ProcessRole(pGuild, it); + + BuildStatusList(pGuild, si); + + for (auto &it : pRoot["channels"]) + ProcessGuildChannel(pGuild, it); + + if (!pGuild->bSynced && getByte(si->hContact, "EnableSync")) + GatewaySendGuildInfo(pGuild); + + // store all guild members + for (auto &it : pRoot["members"]) { + auto *pm = ProcessGuildUser(pGuild, it); + + CMStringW wszNick = it["nick"].as_mstring(); + if (!wszNick.IsEmpty()) + pm->wszNick = wszNick; + + pm->iStatus = ID_STATUS_OFFLINE; + } + + // parse online statuses + for (auto &it : pRoot["presences"]) { + CDiscordGuildMember *gm = pGuild->FindUser(::getId(it["user"]["id"])); + if (gm != nullptr) + gm->iStatus = StrToStatus(it["status"].as_mstring()); + } + + for (auto &it : pGuild->arChatUsers) + AddGuildUser(pGuild, *it); + + if (!m_bTerminated) + ForkThread(&CDiscordProto::BatchChatCreate, pGuild); + + pGuild->bSynced = true; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +CDiscordUser* CDiscordProto::ProcessGuildChannel(CDiscordGuild *pGuild, const JSONNode &pch) +{ + CMStringW wszChannelId = pch["id"].as_mstring(); + SnowFlake channelId = _wtoi64(wszChannelId); + CMStringW wszName = pch["name"].as_mstring(); + CDiscordUser *pUser; + + // filter our all channels but the text ones + switch (pch["type"].as_int()) { + case 4: // channel group + if (!m_bUseGuildGroups) // ignore groups when they aren't enabled + return nullptr; + + pUser = FindUserByChannel(channelId); + if (pUser == nullptr) { + // missing channel - create it + pUser = new CDiscordUser(channelId); + pUser->bIsPrivate = false; + pUser->channelId = channelId; + pUser->bIsGroup = true; + arUsers.insert(pUser); + + pGuild->arChannels.insert(pUser); + + MGROUP grpId = Clist_GroupCreate(pGuild->groupId, wszName); + pUser->wszChannelName = Clist_GroupGetName(grpId); + } + return pUser; + + case 0: // text channel + pUser = FindUserByChannel(channelId); + if (pUser == nullptr) { + // missing channel - create it + pUser = new CDiscordUser(channelId); + pUser->bIsPrivate = false; + pUser->channelId = channelId; + arUsers.insert(pUser); + } + + if (pGuild->arChannels.find(pUser) == nullptr) + pGuild->arChannels.insert(pUser); + + pUser->wszUsername = wszChannelId; + if (m_bUseGuildGroups) + pUser->wszChannelName = L"#" + wszName; + else + pUser->wszChannelName = pGuild->wszName + L"#" + wszName; + pUser->wszTopic = pch["topic"].as_mstring(); + pUser->pGuild = pGuild; + pUser->lastMsgId = ::getId(pch["last_message_id"]); + pUser->parentId = _wtoi64(pch["parent_id"].as_mstring()); + return pUser; + } + + return nullptr; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +CDiscordGuildMember* CDiscordProto::ProcessGuildUser(CDiscordGuild *pGuild, const JSONNode &pRoot, bool *pbNew) +{ + auto& pUser = pRoot["user"]; + + bool bNew = false; + CMStringW wszUserId = pUser["id"].as_mstring(); + SnowFlake userId = _wtoi64(wszUserId); + CDiscordGuildMember *pm = pGuild->FindUser(userId); + if (pm == nullptr) { + pm = new CDiscordGuildMember(userId); + pGuild->arChatUsers.insert(pm); + bNew = true; + } + + pm->wszDiscordId = pUser["username"].as_mstring() + L"#" + pUser["discriminator"].as_mstring(); + pm->wszNick = pRoot["nick"].as_mstring(); + if (pm->wszNick.IsEmpty()) + pm->wszNick = pUser["username"].as_mstring(); + else + bNew = true; + + if (userId == pGuild->ownerId) + pm->wszRole = L"@owner"; + else { + CDiscordRole *pRole = nullptr; + for (auto &itr : pRoot["roles"]) { + SnowFlake roleId = ::getId(itr); + if (pRole = pGuild->arRoles.find((CDiscordRole *)&roleId)) + break; + } + pm->wszRole = (pRole == nullptr) ? L"@everyone" : pRole->wszName; + } + + if (pbNew) + *pbNew = bNew; + return pm; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +void CDiscordProto::ProcessChatUser(CDiscordUser *pChat, const CMStringW &wszUserId, const JSONNode &pRoot) +{ + // input data control + SnowFlake userId = _wtoi64(wszUserId); + CDiscordGuild *pGuild = pChat->pGuild; + if (pGuild == nullptr || userId == 0) + return; + + // does user exist? if yes, there's nothing to do + auto *pm = pGuild->FindUser(userId); + if (pm != nullptr) + return; + + // otherwise let's create a user and insert him into all guild's chats + pm = new CDiscordGuildMember(userId); + pm->wszDiscordId = pRoot["author"]["username"].as_mstring() + L"#" + pRoot["author"]["discriminator"].as_mstring(); + pm->wszNick = pRoot["nick"].as_mstring(); + if (pm->wszNick.IsEmpty()) + pm->wszNick = pRoot["author"]["username"].as_mstring(); + pGuild->arChatUsers.insert(pm); + + debugLogA("add missing user to chat: id=%lld, nick=%S", userId, pm->wszNick.c_str()); + AddGuildUser(pGuild, *pm); +} + +///////////////////////////////////////////////////////////////////////////////////////// + +void CDiscordProto::AddGuildUser(CDiscordGuild *pGuild, const CDiscordGuildMember &pUser) +{ + int flags = 0; + switch (pUser.iStatus) { + case ID_STATUS_ONLINE: case ID_STATUS_NA: case ID_STATUS_DND: + flags = 1; + break; + } + + auto *pStatus = g_chatApi.TM_FindStatus(pGuild->pParentSi->pStatuses, pUser.wszRole); + + wchar_t wszUserId[100]; + _i64tow_s(pUser.userId, wszUserId, _countof(wszUserId), 10); + + auto *pu = g_chatApi.UM_AddUser(pGuild->pParentSi, wszUserId, pUser.wszNick, (pStatus) ? pStatus->iStatus : 0); + pu->iStatusEx = flags; + if (pUser.userId == m_ownId) + pGuild->pParentSi->pMe = pu; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +void CDiscordGuild::LoadFromFile() +{ + int fileNo = _wopen(GetCacheFile(), O_TEXT | O_RDONLY); + if (fileNo == -1) + return; + + int fSize = ::filelength(fileNo); + ptrA json((char*)mir_alloc(fSize + 1)); + read(fileNo, json, fSize); + close(fileNo); + + JSONNode cached = JSONNode::parse(json); + for (auto &it : cached) { + SnowFlake userId = getId(it["id"]); + auto *pUser = FindUser(userId); + if (pUser == nullptr) { + pUser = new CDiscordGuildMember(userId); + arChatUsers.insert(pUser); + } + + pUser->wszNick = it["n"].as_mstring(); + pUser->wszRole = it["r"].as_mstring(); + } +} + +void CDiscordGuild ::SaveToFile() +{ + JSONNode members(JSON_ARRAY); + for (auto &it : arChatUsers) { + JSONNode member; + member << INT64_PARAM("id", it->userId) << WCHAR_PARAM("n", it->wszNick) << WCHAR_PARAM("r", it->wszRole); + members << member; + } + + CMStringW wszFileName(GetCacheFile()); + CreatePathToFileW(wszFileName); + int fileNo = _wopen(wszFileName, O_CREAT | O_TRUNC | O_TEXT | O_WRONLY); + if (fileNo != -1) { + std::string json = members.write_formatted(); + write(fileNo, json.c_str(), (int)json.size()); + close(fileNo); + } +} diff --git a/protocols/Discord/src/http.cpp b/protocols/Discord/src/http.cpp new file mode 100644 index 0000000000..2facf00af7 --- /dev/null +++ b/protocols/Discord/src/http.cpp @@ -0,0 +1,155 @@ +/* +Copyright © 2016-22 Miranda NG team + +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, see <http://www.gnu.org/licenses/>. +*/ + +#include "stdafx.h" + +void CDiscordProto::Push(AsyncHttpRequest *pReq, int iTimeout) +{ + pReq->timeout = iTimeout; + { + mir_cslock lck(m_csHttpQueue); + m_arHttpQueue.insert(pReq); + } + SetEvent(m_evRequestsQueue); +} + +void CDiscordProto::SaveToken(const JSONNode &data) +{ + CMStringA szToken = data["token"].as_mstring(); + if (!szToken.IsEmpty()) + m_szTempToken = szToken.Detach(); +} + +///////////////////////////////////////////////////////////////////////////////////////// + +static LONG g_reqNum = 0; + +AsyncHttpRequest::AsyncHttpRequest(CDiscordProto *ppro, int iRequestType, LPCSTR _url, MTHttpRequestHandler pFunc, JSONNode *pRoot) +{ + if (*_url == '/') { // relative url leads to a site + m_szUrl = "https://discord.com/api/v8"; + m_szUrl += _url; + m_bMainSite = true; + } + else { + m_szUrl = _url; + m_bMainSite = false; + } + + flags = NLHRF_HTTP11 | NLHRF_REDIRECT | NLHRF_SSL; + if (ppro->m_szAccessToken != nullptr) { + AddHeader("Authorization", ppro->m_szAccessToken); + flags |= NLHRF_DUMPASTEXT | NLHRF_NODUMPHEADERS; + } + else flags |= NLHRF_NODUMPSEND; + + if (pRoot != nullptr) { + ptrW text(json_write(pRoot)); + pData = mir_utf8encodeW(text); + dataLength = (int)mir_strlen(pData); + + AddHeader("Content-Type", "application/json"); + } + + m_pFunc = pFunc; + requestType = iRequestType; + m_iErrorCode = 0; + m_iReqNum = ::InterlockedIncrement(&g_reqNum); +} + +JsonReply::JsonReply(NETLIBHTTPREQUEST *pReply) +{ + if (pReply == nullptr) { + m_errorCode = 500; + return; + } + + m_errorCode = pReply->resultCode; + + m_root = json_parse(pReply->pData); + if (m_root == nullptr) + m_errorCode = 500; +} + +JsonReply::~JsonReply() +{ + json_delete(m_root); +} + +///////////////////////////////////////////////////////////////////////////////////////// + +void CDiscordProto::ServerThread(void*) +{ + m_szAccessToken = getStringA("AccessToken"); + m_hAPIConnection = nullptr; + m_bTerminated = false; + + debugLogA("CDiscordProto::WorkerThread: %s", "entering"); + + if (m_szAccessToken != nullptr) + RetrieveMyInfo(); // try to receive a response from server + else { + if (mir_wstrlen(m_wszEmail) == 0) { + ConnectionFailed(LOGINERR_BADUSERID); + return; + } + + ptrW wszPassword(getWStringA(DB_KEY_PASSWORD)); + if (wszPassword == nullptr) { + ConnectionFailed(LOGINERR_WRONGPASSWORD); + return; + } + + JSONNode root; root << WCHAR_PARAM("email", m_wszEmail) << WCHAR_PARAM("password", wszPassword); + Push(new AsyncHttpRequest(this, REQUEST_POST, "/auth/login", &CDiscordProto::OnReceiveToken, &root)); + } + + while (true) { + WaitForSingleObject(m_evRequestsQueue, 1000); + if (m_bTerminated) + break; + + AsyncHttpRequest *pReq; + bool need_sleep = false; + while (true) { + { + mir_cslock lck(m_csHttpQueue); + if (m_arHttpQueue.getCount() == 0) + break; + + pReq = m_arHttpQueue[0]; + m_arHttpQueue.remove(0); + need_sleep = (m_arHttpQueue.getCount() > 1); + } + if (m_bTerminated) + break; + ExecuteRequest(pReq); + if (need_sleep) { + Sleep(330); + debugLogA("CDiscordProto::WorkerThread: %s", "need to sleep"); + } + } + } + + m_hWorkerThread = nullptr; + if (m_hAPIConnection) { + Netlib_CloseHandle(m_hAPIConnection); + m_hAPIConnection = nullptr; + } + + debugLogA("CDiscordProto::WorkerThread: %s", "leaving"); +} diff --git a/protocols/Discord/src/main.cpp b/protocols/Discord/src/main.cpp new file mode 100644 index 0000000000..c615047d00 --- /dev/null +++ b/protocols/Discord/src/main.cpp @@ -0,0 +1,71 @@ +/* +Copyright © 2016-22 Miranda NG team + +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, see <http://www.gnu.org/licenses/>. +*/ + +#include "stdafx.h" + +CMPlugin g_plugin; + +///////////////////////////////////////////////////////////////////////////////////////// + +PLUGININFOEX pluginInfoEx = { + sizeof(PLUGININFOEX), + __PLUGIN_NAME, + PLUGIN_MAKE_VERSION(__MAJOR_VERSION, __MINOR_VERSION, __RELEASE_NUM, __BUILD_NUM), + __DESCRIPTION, + __AUTHOR, + __COPYRIGHT, + __AUTHORWEB, + UNICODE_AWARE, + // {88928401-2CE8-4568-AAA7-226141870CBF} + { 0x88928401, 0x2ce8, 0x4568, { 0xaa, 0xa7, 0x22, 0x61, 0x41, 0x87, 0x0c, 0xbf } } +}; + +CMPlugin::CMPlugin() : + ACCPROTOPLUGIN<CDiscordProto>("Discord", pluginInfoEx) +{ + SetUniqueId(DB_KEY_ID); +} + +///////////////////////////////////////////////////////////////////////////////////////// +// Interface information + +extern "C" __declspec(dllexport) const MUUID MirandaInterfaces[] = { MIID_PROTOCOL, MIID_LAST }; + +///////////////////////////////////////////////////////////////////////////////////////// +// Load + +IconItem g_iconList[] = +{ + { LPGEN("Main icon"), "main", IDI_MAIN }, + { LPGEN("Group chats"), "groupchat", IDI_GROUPCHAT }, + { LPGEN("Call"), "voicecall", IDI_VOICE_CALL }, + { LPGEN("Call ended"), "voiceend", IDI_VOICE_ENDED } +}; + +static int OnModulesLoaded(WPARAM, LPARAM) +{ + g_plugin.bVoiceService = ServiceExists(MS_VOICESERVICE_REGISTER); + return 0; +} + +int CMPlugin::Load() +{ + HookEvent(ME_SYSTEM_MODULESLOADED, &OnModulesLoaded); + + g_plugin.registerIcon("Protocols/Discord", g_iconList); + return 0; +} diff --git a/protocols/Discord/src/menus.cpp b/protocols/Discord/src/menus.cpp new file mode 100644 index 0000000000..e88d91aa43 --- /dev/null +++ b/protocols/Discord/src/menus.cpp @@ -0,0 +1,172 @@ +/* +Copyright © 2016-22 Miranda NG team + +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, see <http://www.gnu.org/licenses/>. +*/ + +#include "stdafx.h" + +INT_PTR CDiscordProto::OnMenuCopyId(WPARAM hContact, LPARAM) +{ + CopyId(CMStringW(FORMAT, L"%s#%d", getMStringW(hContact, DB_KEY_NICK).c_str(), getDword(hContact, DB_KEY_DISCR))); + return 0; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +INT_PTR CDiscordProto::OnMenuCreateChannel(WPARAM hContact, LPARAM) +{ + ENTER_STRING es = { m_szModuleName, "channel_name", TranslateT("Enter channel name"), nullptr, ESF_COMBO, 5 }; + if (EnterString(&es)) { + JSONNode roles(JSON_ARRAY); roles.set_name("permission_overwrites"); + JSONNode root; root << INT_PARAM("type", 0) << WCHAR_PARAM("name", es.ptszResult) << roles; + CMStringA szUrl(FORMAT, "/guilds/%lld/channels", getId(hContact, DB_KEY_CHANNELID)); + Push(new AsyncHttpRequest(this, REQUEST_POST, szUrl, nullptr, &root)); + mir_free(es.ptszResult); + } + return 0; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +INT_PTR CDiscordProto::OnMenuJoinGuild(WPARAM, LPARAM) +{ + ENTER_STRING es = { m_szModuleName, "guild_name", TranslateT("Enter invitation code you received"), nullptr, ESF_COMBO, 5 }; + if (EnterString(&es)) { + CMStringA szUrl(FORMAT, "/invite/%S", es.ptszResult); + Push(new AsyncHttpRequest(this, REQUEST_POST, szUrl, nullptr)); + mir_free(es.ptszResult); + } + return 0; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +INT_PTR CDiscordProto::OnMenuLeaveGuild(WPARAM hContact, LPARAM) +{ + if (IDYES == MessageBox(nullptr, TranslateT("Do you really want to leave the guild?"), m_tszUserName, MB_ICONQUESTION | MB_YESNOCANCEL)) { + CMStringA szUrl(FORMAT, "/users/@me/guilds/%lld", getId(hContact, DB_KEY_CHANNELID)); + Push(new AsyncHttpRequest(this, REQUEST_DELETE, szUrl, nullptr)); + } + return 0; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +INT_PTR CDiscordProto::OnMenuLoadHistory(WPARAM hContact, LPARAM) +{ + auto *pUser = FindUser(getId(hContact, DB_KEY_ID)); + if (pUser) { + RetrieveHistory(pUser, MSG_AFTER, 0, 100); + delSetting(hContact, DB_KEY_LASTMSGID); + } + return 0; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +INT_PTR CDiscordProto::OnMenuToggleSync(WPARAM hContact, LPARAM) +{ + bool bEnabled = !getBool(hContact, "EnableSync"); + setByte(hContact, "EnableSync", bEnabled); + + if (bEnabled) + if (auto *pGuild = FindGuild(getId(hContact, DB_KEY_CHANNELID))) + GatewaySendGuildInfo(pGuild); + return 0; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +int CDiscordProto::OnMenuPrebuild(WPARAM hContact, LPARAM) +{ + // "Leave guild" menu item should be visible only for the guild contacts + bool bIsGuild = getByte(hContact, "ChatRoom") == 2; + Menu_ShowItem(m_hMenuLeaveGuild, bIsGuild); + Menu_ShowItem(m_hMenuCreateChannel, bIsGuild); + Menu_ShowItem(m_hMenuToggleSync, bIsGuild); + + if (!bIsGuild && getWord(hContact, "ApparentMode") != 0) + Menu_ShowItem(GetMenuItem(PROTO_MENU_REQ_AUTH), true); + + if (getByte(hContact, "EnableSync")) + Menu_ModifyItem(m_hMenuToggleSync, LPGENW("Disable sync"), Skin_GetIconHandle(SKINICON_CHAT_LEAVE)); + else + Menu_ModifyItem(m_hMenuToggleSync, LPGENW("Enable sync"), Skin_GetIconHandle(SKINICON_CHAT_JOIN)); + return 0; +} + +///////////////////////////////////////////////////////////////////////////////////////// +// Protocol menu items + +void CDiscordProto::OnBuildProtoMenu() +{ + CMenuItem mi(&g_plugin); + mi.root = Menu_GetProtocolRoot(this); + mi.flags = CMIF_UNMOVABLE; + + mi.pszService = "/JoinGuild"; + CreateProtoService(mi.pszService, &CDiscordProto::OnMenuJoinGuild); + mi.name.a = LPGEN("Join guild"); + mi.position = 200001; + mi.hIcolibItem = g_iconList[1].hIcolib; + Menu_AddProtoMenuItem(&mi, m_szModuleName); + + mi.pszService = "/CopyId"; + CreateProtoService(mi.pszService, &CDiscordProto::OnMenuCopyId); + mi.name.a = LPGEN("Copy my Discord ID"); + mi.position = 200002; + mi.hIcolibItem = Skin_GetIconHandle(SKINICON_OTHER_USERONLINE); + Menu_AddProtoMenuItem(&mi, m_szModuleName); +} + +///////////////////////////////////////////////////////////////////////////////////////// +// Contact menu items + +void CDiscordProto::InitMenus() +{ + CMenuItem mi(&g_plugin); + mi.pszService = "/LeaveGuild"; + CreateProtoService(mi.pszService, &CDiscordProto::OnMenuLeaveGuild); + SET_UID(mi, 0x6EF11AD6, 0x6111, 0x4E29, 0xBA, 0x8B, 0xA7, 0xB2, 0xE0, 0x22, 0xE1, 0x8C); + mi.name.a = LPGEN("Leave guild"); + mi.position = -200001000; + mi.hIcolibItem = Skin_GetIconHandle(SKINICON_CHAT_LEAVE); + m_hMenuLeaveGuild = Menu_AddContactMenuItem(&mi, m_szModuleName); + + mi.pszService = "/CreateChannel"; + CreateProtoService(mi.pszService, &CDiscordProto::OnMenuCreateChannel); + SET_UID(mi, 0x6EF11AD6, 0x6111, 0x4E29, 0xBA, 0x8B, 0xA7, 0xB2, 0xE0, 0x22, 0xE1, 0x8D); + mi.name.a = LPGEN("Create new channel"); + mi.position = -200001001; + mi.hIcolibItem = Skin_GetIconHandle(SKINICON_OTHER_ADDCONTACT); + m_hMenuCreateChannel = Menu_AddContactMenuItem(&mi, m_szModuleName); + + SET_UID(mi, 0x6EF11AD6, 0x6111, 0x4E29, 0xBA, 0x8B, 0xA7, 0xB2, 0xE0, 0x22, 0xE1, 0x8E); + mi.pszService = "/CopyId"; + mi.name.a = LPGEN("Copy ID"); + mi.position = -200001002; + mi.hIcolibItem = Skin_GetIconHandle(SKINICON_OTHER_USERONLINE); + Menu_AddContactMenuItem(&mi, m_szModuleName); + + mi.pszService = "/ToggleSync"; + CreateProtoService(mi.pszService, &CDiscordProto::OnMenuToggleSync); + SET_UID(mi, 0x6EF11AD6, 0x6111, 0x4E29, 0xBA, 0x8B, 0xA7, 0xB2, 0xE0, 0x22, 0xE1, 0x8F); + mi.name.a = LPGEN("Enable guild sync"); + mi.position = -200001003; + mi.hIcolibItem = Skin_GetIconHandle(SKINICON_CHAT_JOIN); + m_hMenuToggleSync = Menu_AddContactMenuItem(&mi, m_szModuleName); + + HookProtoEvent(ME_CLIST_PREBUILDCONTACTMENU, &CDiscordProto::OnMenuPrebuild); +} diff --git a/protocols/Discord/src/options.cpp b/protocols/Discord/src/options.cpp new file mode 100644 index 0000000000..86f3519df8 --- /dev/null +++ b/protocols/Discord/src/options.cpp @@ -0,0 +1,100 @@ +/* +Copyright © 2016-22 Miranda NG team + +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, see <http://www.gnu.org/licenses/>. +*/ + +#include "stdafx.h" + +///////////////////////////////////////////////////////////////////////////////////////// + +class CDiscardAccountOptions : public CProtoDlgBase<CDiscordProto> +{ + CCtrlCheck chkUseChats, chkHideChats, chkUseGroups, chkDeleteMsgs; + CCtrlEdit m_edGroup, m_edUserName, m_edPassword; + ptrW m_wszOldGroup; + +public: + CDiscardAccountOptions(CDiscordProto *ppro, int iDlgID, bool bFullDlg) : + CProtoDlgBase<CDiscordProto>(ppro, iDlgID), + m_edGroup(this, IDC_GROUP), + m_edUserName(this, IDC_USERNAME), + m_edPassword(this, IDC_PASSWORD), + chkUseChats(this, IDC_USEGUILDS), + chkHideChats(this, IDC_HIDECHATS), + chkUseGroups(this, IDC_USEGROUPS), + chkDeleteMsgs(this, IDC_DELETE_MSGS), + m_wszOldGroup(mir_wstrdup(ppro->m_wszDefaultGroup)) + { + CreateLink(m_edGroup, ppro->m_wszDefaultGroup); + CreateLink(m_edUserName, ppro->m_wszEmail); + if (bFullDlg) { + CreateLink(chkUseChats, ppro->m_bUseGroupchats); + CreateLink(chkHideChats, ppro->m_bHideGroupchats); + CreateLink(chkUseGroups, ppro->m_bUseGuildGroups); + CreateLink(chkDeleteMsgs, ppro->m_bSyncDeleteMsgs); + + chkUseChats.OnChange = Callback(this, &CDiscardAccountOptions::onChange_GroupChats); + } + } + + bool OnInitDialog() override + { + ptrW buf(m_proto->getWStringA(DB_KEY_PASSWORD)); + if (buf) + m_edPassword.SetText(buf); + return true; + } + + bool OnApply() override + { + if (mir_wstrcmp(m_proto->m_wszDefaultGroup, m_wszOldGroup)) + Clist_GroupCreate(0, m_proto->m_wszDefaultGroup); + + ptrW buf(m_edPassword.GetText()); + m_proto->setWString(DB_KEY_PASSWORD, buf); + return true; + } + + void onChange_GroupChats(CCtrlCheck*) + { + bool bEnabled = chkUseChats.GetState(); + chkHideChats.Enable(bEnabled); + chkUseGroups.Enable(bEnabled); + } +}; + +///////////////////////////////////////////////////////////////////////////////////////// + +INT_PTR CDiscordProto::SvcCreateAccMgrUI(WPARAM, LPARAM hwndParent) +{ + CDiscardAccountOptions *pDlg = new CDiscardAccountOptions(this, IDD_OPTIONS_ACCMGR, false); + pDlg->SetParent((HWND)hwndParent); + pDlg->Create(); + return (INT_PTR)pDlg->GetHwnd(); +} + +int CDiscordProto::OnOptionsInit(WPARAM wParam, LPARAM) +{ + OPTIONSDIALOGPAGE odp = {}; + odp.szTitle.w = m_tszUserName; + odp.flags = ODPF_UNICODE; + odp.szGroup.w = LPGENW("Network"); + + odp.position = 1; + odp.szTab.w = LPGENW("Account"); + odp.pDialog = new CDiscardAccountOptions(this, IDD_OPTIONS_ACCOUNT, true); + g_plugin.addOptions(wParam, &odp); + return 0; +} diff --git a/protocols/Discord/src/proto.cpp b/protocols/Discord/src/proto.cpp new file mode 100644 index 0000000000..972c6ec312 --- /dev/null +++ b/protocols/Discord/src/proto.cpp @@ -0,0 +1,768 @@ +/* +Copyright © 2016-22 Miranda NG team + +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, see <http://www.gnu.org/licenses/>. +*/ + +#include "stdafx.h" + +static int compareMessages(const COwnMessage *p1, const COwnMessage *p2) +{ + return compareInt64(p1->nonce, p2->nonce); +} + +static int compareRequests(const AsyncHttpRequest *p1, const AsyncHttpRequest *p2) +{ + return p1->m_iReqNum - p2->m_iReqNum; +} + +int compareUsers(const CDiscordUser *p1, const CDiscordUser *p2) +{ + return compareInt64(p1->id, p2->id); +} + +static int compareGuilds(const CDiscordGuild *p1, const CDiscordGuild *p2) +{ + return compareInt64(p1->id, p2->id); +} + +CDiscordProto::CDiscordProto(const char *proto_name, const wchar_t *username) : + PROTO<CDiscordProto>(proto_name, username), + m_impl(*this), + m_arHttpQueue(10, compareRequests), + m_evRequestsQueue(CreateEvent(nullptr, FALSE, FALSE, nullptr)), + arUsers(10, compareUsers), + arGuilds(1, compareGuilds), + arMarkReadQueue(1, compareUsers), + arOwnMessages(1, compareMessages), + arVoiceCalls(1), + + m_wszEmail(this, "Email", L""), + m_wszDefaultGroup(this, "GroupName", DB_KEYVAL_GROUP), + m_bUseGroupchats(this, "UseGroupChats", true), + m_bHideGroupchats(this, "HideChats", true), + m_bUseGuildGroups(this, "UseGuildGroups", false), + m_bSyncDeleteMsgs(this, "DeleteServerMsgs", true) +{ + // Services + CreateProtoService(PS_CREATEACCMGRUI, &CDiscordProto::SvcCreateAccMgrUI); + + CreateProtoService(PS_GETAVATARINFO, &CDiscordProto::GetAvatarInfo); + CreateProtoService(PS_GETAVATARCAPS, &CDiscordProto::GetAvatarCaps); + CreateProtoService(PS_GETMYAVATAR, &CDiscordProto::GetMyAvatar); + CreateProtoService(PS_SETMYAVATAR, &CDiscordProto::SetMyAvatar); + + CreateProtoService(PS_MENU_REQAUTH, &CDiscordProto::RequestFriendship); + CreateProtoService(PS_MENU_LOADHISTORY, &CDiscordProto::OnMenuLoadHistory); + + CreateProtoService(PS_VOICE_CAPS, &CDiscordProto::VoiceCaps); + + // Events + HookProtoEvent(ME_OPT_INITIALISE, &CDiscordProto::OnOptionsInit); + HookProtoEvent(ME_DB_EVENT_MARKED_READ, &CDiscordProto::OnDbEventRead); + HookProtoEvent(ME_PROTO_ACCLISTCHANGED, &CDiscordProto::OnAccountChanged); + + HookProtoEvent(PE_VOICE_CALL_STATE, &CDiscordProto::OnVoiceState); + + // database + db_set_resident(m_szModuleName, "XStatusMsg"); + + // custom events + DBEVENTTYPEDESCR dbEventType = {}; + dbEventType.module = m_szModuleName; + dbEventType.flags = DETF_HISTORY | DETF_MSGWINDOW; + + dbEventType.eventType = EVENT_INCOMING_CALL; + dbEventType.descr = Translate("Incoming call"); + dbEventType.eventIcon = g_plugin.getIconHandle(IDI_VOICE_CALL); + DbEvent_RegisterType(&dbEventType); + + dbEventType.eventType = EVENT_CALL_FINISHED; + dbEventType.descr = Translate("Call ended"); + dbEventType.eventIcon = g_plugin.getIconHandle(IDI_VOICE_ENDED); + DbEvent_RegisterType(&dbEventType); + + // Groupchat initialization + GCREGISTER gcr = {}; + gcr.dwFlags = GC_TYPNOTIF | GC_CHANMGR; + gcr.ptszDispName = m_tszUserName; + gcr.pszModule = m_szModuleName; + Chat_Register(&gcr); + + // Network initialization + CMStringW descr; + NETLIBUSER nlu = {}; + + nlu.szSettingsModule = m_szModuleName; + nlu.flags = NUF_OUTGOING | NUF_HTTPCONNS | NUF_UNICODE; + descr.Format(TranslateT("%s server connection"), m_tszUserName); + nlu.szDescriptiveName.w = descr.GetBuffer(); + m_hNetlibUser = Netlib_RegisterUser(&nlu); + + CMStringA module(FORMAT, "%s.Gateway", m_szModuleName); + nlu.szSettingsModule = module.GetBuffer(); + nlu.flags = NUF_OUTGOING | NUF_UNICODE; + descr.Format(TranslateT("%s gateway connection"), m_tszUserName); + nlu.szDescriptiveName.w = descr.GetBuffer(); + m_hGatewayNetlibUser = Netlib_RegisterUser(&nlu); +} + +CDiscordProto::~CDiscordProto() +{ + debugLogA("CDiscordProto::~CDiscordProto"); + + for (auto &msg : m_wszStatusMsg) + mir_free(msg); + + arUsers.destroy(); + + m_arHttpQueue.destroy(); + ::CloseHandle(m_evRequestsQueue); +} + +///////////////////////////////////////////////////////////////////////////////////////// + +void CDiscordProto::OnModulesLoaded() +{ + std::vector<MCONTACT> lostIds; + + // Fill users list + for (auto &hContact : AccContacts()) { + CDiscordUser *pNew = new CDiscordUser(getId(hContact, DB_KEY_ID)); + pNew->hContact = hContact; + pNew->lastMsgId = getId(hContact, DB_KEY_LASTMSGID); + pNew->wszUsername = ptrW(getWStringA(hContact, DB_KEY_NICK)); + pNew->iDiscriminator = getDword(hContact, DB_KEY_DISCR); + + // set EnableSync = 1 by default for all existing guilds + switch (getByte(hContact, "ChatRoom")) { + case 2: // guild + delSetting(hContact, DB_KEY_CHANNELID); + if (getDword(hContact, "EnableSync", -1) == -1) + setDword(hContact, "EnableSync", 1); + break; + + case 1: // group chat + pNew->channelId = getId(hContact, DB_KEY_CHANNELID); + if (!pNew->channelId) { + lostIds.push_back(hContact); + delete pNew; + continue; + } + break; + + default: + pNew->channelId = getId(hContact, DB_KEY_CHANNELID); + break; + } + arUsers.insert(pNew); + } + + for (auto &hContact: lostIds) + db_delete_contact(hContact); + + // Clist + Clist_GroupCreate(0, m_wszDefaultGroup); + + HookProtoEvent(ME_GC_EVENT, &CDiscordProto::GroupchatEventHook); + HookProtoEvent(ME_GC_BUILDMENU, &CDiscordProto::GroupchatMenuHook); + + InitMenus(); + + // Voice support + if (g_plugin.bVoiceService) { + VOICE_MODULE voice = {}; + voice.cbSize = sizeof(voice); + voice.name = m_szModuleName; + voice.description = TranslateT("Discord voice call"); + voice.icon = m_hProtoIcon; + voice.flags = VOICE_CAPS_CALL_CONTACT | VOICE_CAPS_VOICE; + CallService(MS_VOICESERVICE_REGISTER, (WPARAM)&voice, 0); + } +} + +void CDiscordProto::OnShutdown() +{ + debugLogA("CDiscordProto::OnPreShutdown"); + + m_bTerminated = true; + SetEvent(m_evRequestsQueue); + + for (auto &it : arGuilds) + it->SaveToFile(); + + if (m_hGatewayConnection) + Netlib_Shutdown(m_hGatewayConnection); + + if (g_plugin.bVoiceService) + CallService(MS_VOICESERVICE_UNREGISTER, (WPARAM)m_szModuleName, 0); +} + +///////////////////////////////////////////////////////////////////////////////////////// + +INT_PTR CDiscordProto::GetCaps(int type, MCONTACT) +{ + switch (type) { + case PFLAGNUM_1: + return PF1_IM | PF1_MODEMSG | PF1_MODEMSGRECV | PF1_SERVERCLIST | PF1_BASICSEARCH | PF1_EXTSEARCH | PF1_ADDSEARCHRES | PF1_FILESEND; + case PFLAGNUM_2: + return PF2_ONLINE | PF2_SHORTAWAY | PF2_LONGAWAY | PF2_HEAVYDND | PF2_INVISIBLE; + case PFLAGNUM_3: + return PF2_ONLINE | PF2_LONGAWAY | PF2_HEAVYDND | PF2_INVISIBLE; + case PFLAGNUM_4: + return PF4_FORCEAUTH | PF4_NOCUSTOMAUTH | PF4_NOAUTHDENYREASON | PF4_SUPPORTTYPING | PF4_SUPPORTIDLE | PF4_AVATARS | PF4_IMSENDOFFLINE | PF4_SERVERMSGID | PF4_OFFLINEFILES; + case PFLAG_UNIQUEIDTEXT: + return (INT_PTR)TranslateT("User ID"); + } + return 0; +} + +int CDiscordProto::SetStatus(int iNewStatus) +{ + debugLogA("CDiscordProto::SetStatus iNewStatus = %d, m_iStatus = %d, m_iDesiredStatus = %d m_hWorkerThread = %p", iNewStatus, m_iStatus, m_iDesiredStatus, m_hWorkerThread); + + if (iNewStatus == m_iStatus) + return 0; + + m_iDesiredStatus = iNewStatus; + int iOldStatus = m_iStatus; + + // go offline + if (iNewStatus == ID_STATUS_OFFLINE) { + if (m_bOnline) { + SetServerStatus(ID_STATUS_OFFLINE); + ShutdownSession(); + } + m_iStatus = m_iDesiredStatus; + setAllContactStatuses(ID_STATUS_OFFLINE, false); + + ProtoBroadcastAck(0, ACKTYPE_STATUS, ACKRESULT_SUCCESS, (HANDLE)iOldStatus, m_iStatus); + } + // not logged in? come on + else if (m_hWorkerThread == nullptr && !IsStatusConnecting(m_iStatus)) { + m_iStatus = ID_STATUS_CONNECTING; + ProtoBroadcastAck(0, ACKTYPE_STATUS, ACKRESULT_SUCCESS, (HANDLE)iOldStatus, m_iStatus); + m_hWorkerThread = ForkThreadEx(&CDiscordProto::ServerThread, nullptr, nullptr); + } + else if (m_bOnline) { + debugLogA("setting server online status to %d", iNewStatus); + SetServerStatus(iNewStatus); + } + + return 0; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +static INT_PTR CALLBACK AdvancedSearchDlgProc(HWND hwndDlg, UINT msg, WPARAM wParam, LPARAM) +{ + switch (msg) { + case WM_INITDIALOG: + TranslateDialogDefault(hwndDlg); + SetFocus(GetDlgItem(hwndDlg, IDC_NICK)); + return TRUE; + + case WM_COMMAND: + if (HIWORD(wParam) == EN_SETFOCUS) + PostMessage(GetParent(hwndDlg), WM_COMMAND, MAKEWPARAM(0, EN_SETFOCUS), (LPARAM)hwndDlg); + } + return FALSE; +} + +HWND CDiscordProto::CreateExtendedSearchUI(HWND hwndParent) +{ + if (hwndParent) + return CreateDialogParam(g_plugin.getInst(), MAKEINTRESOURCE(IDD_EXTSEARCH), hwndParent, AdvancedSearchDlgProc, 0); + + return nullptr; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +void CDiscordProto::SearchThread(void *param) +{ + Sleep(100); + + PROTOSEARCHRESULT psr = { 0 }; + psr.cbSize = sizeof(psr); + psr.flags = PSR_UNICODE; + psr.nick.w = (wchar_t*)param; + psr.firstName.w = L""; + psr.lastName.w = L""; + psr.id.w = L""; + ProtoBroadcastAck(0, ACKTYPE_SEARCH, ACKRESULT_DATA, (HANDLE)1, (LPARAM)&psr); + + ProtoBroadcastAck(0, ACKTYPE_SEARCH, ACKRESULT_SUCCESS, (HANDLE)1, 0); + mir_free(param); +} + +HWND CDiscordProto::SearchAdvanced(HWND hwndDlg) +{ + if (!m_bOnline || !IsWindow(hwndDlg)) + return nullptr; + + wchar_t wszNick[200]; + GetDlgItemTextW(hwndDlg, IDC_NICK, wszNick, _countof(wszNick)); + if (wszNick[0] == 0) // empty string? reject + return nullptr; + + wchar_t *p = wcschr(wszNick, '#'); + if (p == nullptr) // wrong user id + return nullptr; + + ForkThread(&CDiscordProto::SearchThread, mir_wstrdup(wszNick)); + return (HWND)1; +} + +///////////////////////////////////////////////////////////////////////////////////////// +// Basic search - by SnowFlake + +void CDiscordProto::OnReceiveUserinfo(NETLIBHTTPREQUEST *pReply, AsyncHttpRequest*) +{ + JsonReply root(pReply); + if (!root) { + ProtoBroadcastAck(0, ACKTYPE_SEARCH, ACKRESULT_FAILED, (HANDLE)1); + return; + } + + auto &data = root.data(); + CMStringW wszUserId(data["username"].as_mstring() + L"#" + data["discriminator"].as_mstring()); + ForkThread(&CDiscordProto::SearchThread, wszUserId.Detach()); +} + +HANDLE CDiscordProto::SearchBasic(const wchar_t *wszId) +{ + if (!m_bOnline) + return nullptr; + + CMStringA szUrl = "/users/"; + szUrl.AppendFormat(ptrA(mir_utf8encodeW(wszId))); + Push(new AsyncHttpRequest(this, REQUEST_GET, szUrl, &CDiscordProto::OnReceiveUserinfo)); + return (HANDLE)1; // Success +} + +//////////////////////////////////////////////////////////////////////////////////////// +// Authorization + +int CDiscordProto::AuthRequest(MCONTACT hContact, const wchar_t*) +{ + ptrW wszUsername(getWStringA(hContact, DB_KEY_NICK)); + int iDiscriminator(getDword(hContact, DB_KEY_DISCR, -1)); + if (wszUsername == nullptr || iDiscriminator == -1) + return 1; // error + + JSONNode root; root << WCHAR_PARAM("username", wszUsername) << INT_PARAM("discriminator", iDiscriminator); + Push(new AsyncHttpRequest(this, REQUEST_POST, "/users/@me/relationships", nullptr, &root)); + return 0; +} + +int CDiscordProto::AuthRecv(MCONTACT, PROTORECVEVENT *pre) +{ + return Proto_AuthRecv(m_szModuleName, pre); +} + +int CDiscordProto::Authorize(MEVENT hDbEvent) +{ + DB::EventInfo dbei; + dbei.cbBlob = -1; + if (db_event_get(hDbEvent, &dbei)) return 1; + if (dbei.eventType != EVENTTYPE_AUTHREQUEST) return 1; + if (mir_strcmp(dbei.szModule, m_szModuleName)) return 1; + + JSONNode root; + MCONTACT hContact = DbGetAuthEventContact(&dbei); + CMStringA szUrl(FORMAT, "/users/@me/relationships/%lld", getId(hContact, DB_KEY_ID)); + Push(new AsyncHttpRequest(this, REQUEST_PUT, szUrl, nullptr, &root)); + return 0; +} + +int CDiscordProto::AuthDeny(MEVENT hDbEvent, const wchar_t*) +{ + DB::EventInfo dbei; + dbei.cbBlob = -1; + if (db_event_get(hDbEvent, &dbei)) return 1; + if (dbei.eventType != EVENTTYPE_AUTHREQUEST) return 1; + if (mir_strcmp(dbei.szModule, m_szModuleName)) return 1; + + MCONTACT hContact = DbGetAuthEventContact(&dbei); + RemoveFriend(getId(hContact, DB_KEY_ID)); + return 0; +} + +//////////////////////////////////////////////////////////////////////////////////////// + +MCONTACT CDiscordProto::AddToList(int flags, PROTOSEARCHRESULT *psr) +{ + if (mir_wstrlen(psr->nick.w) == 0) + return 0; + + wchar_t *p = wcschr(psr->nick.w, '#'); + if (p == nullptr) + return 0; + + MCONTACT hContact = db_add_contact(); + Proto_AddToContact(hContact, m_szModuleName); + if (flags & PALF_TEMPORARY) + Contact::RemoveFromList(hContact); + + *p = 0; + CDiscordUser *pUser = new CDiscordUser(0); + pUser->hContact = hContact; + pUser->wszUsername = psr->nick.w; + pUser->iDiscriminator = _wtoi(p + 1); + *p = '#'; + + if (mir_wstrlen(psr->id.w)) { + pUser->id = _wtoi64(psr->id.w); + setId(hContact, DB_KEY_ID, pUser->id); + } + + Clist_SetGroup(hContact, m_wszDefaultGroup); + setWString(hContact, DB_KEY_NICK, pUser->wszUsername); + setDword(hContact, DB_KEY_DISCR, pUser->iDiscriminator); + arUsers.insert(pUser); + + return hContact; +} + +MCONTACT CDiscordProto::AddToListByEvent(int flags, int, MEVENT hDbEvent) +{ + DB::EventInfo dbei; + dbei.cbBlob = -1; + if (db_event_get(hDbEvent, &dbei)) + return 0; + if (mir_strcmp(dbei.szModule, m_szModuleName)) + return 0; + if (dbei.eventType != EVENTTYPE_AUTHREQUEST) + return 0; + + DB::AUTH_BLOB blob(dbei.pBlob); + if (flags & PALF_TEMPORARY) + Contact::RemoveFromList(blob.get_contact()); + else + Contact::PutOnList(blob.get_contact()); + return blob.get_contact(); +} + +//////////////////////////////////////////////////////////////////////////////////////// +// SendMsg + +void CDiscordProto::OnSendMsg(NETLIBHTTPREQUEST *pReply, AsyncHttpRequest *pReq) +{ + JsonReply root(pReply); + if (!root) { + int iReqNum = -1; + for (auto &it : arOwnMessages) + if (it->reqId == pReq->m_iReqNum) { + iReqNum = it->reqId; + arOwnMessages.removeItem(&it); + break; + } + + if (iReqNum != -1) { + CMStringW wszErrorMsg(root.data()["message"].as_mstring()); + if (wszErrorMsg.IsEmpty()) + wszErrorMsg = TranslateT("Message send failed"); + ProtoBroadcastAck(pReq->hContact, ACKTYPE_MESSAGE, ACKRESULT_FAILED, (HANDLE)iReqNum, (LPARAM)wszErrorMsg.c_str()); + } + } +} + +int CDiscordProto::SendMsg(MCONTACT hContact, int /*flags*/, const char *pszSrc) +{ + if (!m_bOnline) { + ProtoBroadcastAsync(hContact, ACKTYPE_MESSAGE, ACKRESULT_FAILED, (HANDLE)1, (LPARAM)TranslateT("Protocol is offline or user isn't authorized yet")); + return 1; + } + + ptrW wszText(mir_utf8decodeW(pszSrc)); + if (wszText == nullptr) + return 0; + + CDiscordUser *pUser = FindUser(getId(hContact, DB_KEY_ID)); + if (pUser == nullptr || pUser->id == 0) + return 0; + + // no channel - we need to create one + if (pUser->channelId == 0) { + JSONNode list(JSON_ARRAY); list.set_name("recipients"); list << SINT64_PARAM("", pUser->id); + JSONNode body; body << list; + CMStringA szUrl(FORMAT, "/users/%lld/channels", m_ownId); + + // theoretically we get the same data from the gateway thread, but there could be a delay + // so we bind data analysis to the http packet reply + mir_cslock lck(m_csHttpQueue); + ExecuteRequest(new AsyncHttpRequest(this, REQUEST_POST, szUrl, &CDiscordProto::OnReceiveCreateChannel, &body)); + if (pUser->channelId == 0) + return 0; + } + + // we generate a random 64-bit integer and pass it to the server + // to distinguish our own messages from these generated by another clients + SnowFlake nonce; Utils_GetRandom(&nonce, sizeof(nonce)); nonce = abs(nonce); + JSONNode body; body << WCHAR_PARAM("content", wszText) << SINT64_PARAM("nonce", nonce); + + CMStringA szUrl(FORMAT, "/channels/%lld/messages", pUser->channelId); + AsyncHttpRequest *pReq = new AsyncHttpRequest(this, REQUEST_POST, szUrl, &CDiscordProto::OnSendMsg, &body); + pReq->hContact = hContact; + arOwnMessages.insert(new COwnMessage(nonce, pReq->m_iReqNum)); + Push(pReq); + return pReq->m_iReqNum; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +void __cdecl CDiscordProto::GetAwayMsgThread(void *param) +{ + Thread_SetName("Jabber: GetAwayMsgThread"); + + auto *pUser = (CDiscordUser *)param; + if (pUser == nullptr) + return; + + if (pUser->wszTopic.IsEmpty()) + ProtoBroadcastAck(pUser->hContact, ACKTYPE_AWAYMSG, ACKRESULT_SUCCESS, (HANDLE)1, 0); + else + ProtoBroadcastAck(pUser->hContact, ACKTYPE_AWAYMSG, ACKRESULT_SUCCESS, (HANDLE)1, (LPARAM)pUser->wszTopic.c_str()); +} + +HANDLE CDiscordProto::GetAwayMsg(MCONTACT hContact) +{ + ForkThread(&CDiscordProto::GetAwayMsgThread, FindUser(getId(hContact, DB_KEY_ID))); + return (HANDLE)1; +} + +int CDiscordProto::SetAwayMsg(int iStatus, const wchar_t *msg) +{ + if (iStatus < ID_STATUS_MIN || iStatus > ID_STATUS_MAX) + return 0; + + wchar_t *&pwszMessage = m_wszStatusMsg[iStatus - ID_STATUS_MIN]; + if (!mir_wstrcmp(msg, pwszMessage)) + return 0; + + replaceStrW(pwszMessage, msg); + + if (m_bOnline) { + JSONNode status; status.set_name("custom_status"); status << WCHAR_PARAM("text", (msg) ? msg : L""); + JSONNode root; root << status; + Push(new AsyncHttpRequest(this, REQUEST_PATCH, "/users/@me/settings", nullptr, &root)); + } + return 0; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +int CDiscordProto::UserIsTyping(MCONTACT hContact, int type) +{ + if (type == PROTOTYPE_SELFTYPING_ON) { + CMStringA szUrl(FORMAT, "/channels/%lld/typing", getId(hContact, DB_KEY_CHANNELID)); + Push(new AsyncHttpRequest(this, REQUEST_POST, szUrl, nullptr)); + } + return 0; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +void CDiscordProto::OnReceiveMarkRead(NETLIBHTTPREQUEST *pReply, AsyncHttpRequest *) +{ + JsonReply root(pReply); + if (root) + SaveToken(root.data()); +} + +void CDiscordProto::SendMarkRead() +{ + mir_cslock lck(csMarkReadQueue); + while (arMarkReadQueue.getCount()) { + CDiscordUser *pUser = arMarkReadQueue[0]; + JSONNode payload; payload << CHAR_PARAM("token", m_szTempToken); + CMStringA szUrl(FORMAT, "/channels/%lld/messages/%lld/ack", pUser->channelId, pUser->lastMsgId); + auto *pReq = new AsyncHttpRequest(this, REQUEST_POST, szUrl, &CDiscordProto::OnReceiveMarkRead, &payload); + Push(pReq); + arMarkReadQueue.remove(0); + } +} + +int CDiscordProto::OnDbEventRead(WPARAM, LPARAM hDbEvent) +{ + MCONTACT hContact = db_event_getContact(hDbEvent); + if (!hContact) + return 0; + + // filter out only events of my protocol + const char *szProto = Proto_GetBaseAccountName(hContact); + if (mir_strcmp(szProto, m_szModuleName)) + return 0; + + if (m_bOnline) { + m_impl.m_markRead.Start(200); + + CDiscordUser *pUser = FindUser(getId(hContact, DB_KEY_ID)); + if (pUser != nullptr) { + mir_cslock lck(csMarkReadQueue); + if (arMarkReadQueue.indexOf(pUser) == -1) + arMarkReadQueue.insert(pUser); + } + } + return 0; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +int CDiscordProto::OnAccountChanged(WPARAM iAction, LPARAM lParam) +{ + if (iAction == PRAC_ADDED) { + PROTOACCOUNT *pa = (PROTOACCOUNT*)lParam; + if (pa && pa->ppro == this) { + m_bUseGroupchats = false; + m_bUseGuildGroups = true; + } + } + + return 0; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +void CDiscordProto::OnContactDeleted(MCONTACT hContact) +{ + CDiscordUser *pUser = FindUser(getId(hContact, DB_KEY_ID)); + if (pUser == nullptr || !m_bOnline) + return; + + if (pUser->channelId) + Push(new AsyncHttpRequest(this, REQUEST_DELETE, CMStringA(FORMAT, "/channels/%lld", pUser->channelId), nullptr)); + + if (pUser->id) + RemoveFriend(pUser->id); +} + +///////////////////////////////////////////////////////////////////////////////////////// + +INT_PTR CDiscordProto::RequestFriendship(WPARAM hContact, LPARAM) +{ + AuthRequest(hContact, 0); + return 0; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +struct SendFileThreadParam +{ + MCONTACT hContact; + CMStringW wszDescr, wszFileName; + + SendFileThreadParam(MCONTACT _p1, LPCWSTR _p2, LPCWSTR _p3) : + hContact(_p1), + wszFileName(_p2), + wszDescr(_p3) + {} +}; + +void CDiscordProto::SendFileThread(void *param) +{ + SendFileThreadParam *p = (SendFileThreadParam*)param; + + FILE *in = _wfopen(p->wszFileName, L"rb"); + if (in == nullptr) { + debugLogA("cannot open file %S for reading", p->wszFileName.c_str()); + LBL_Error: + ProtoBroadcastAck(p->hContact, ACKTYPE_FILE, ACKRESULT_FAILED, param); + delete p; + return; + } + + ProtoBroadcastAck(p->hContact, ACKTYPE_FILE, ACKRESULT_INITIALISING, param); + + char szRandom[16], szRandomText[33]; + Utils_GetRandom(szRandom, _countof(szRandom)); + bin2hex(szRandom, _countof(szRandom), szRandomText); + CMStringA szBoundary(FORMAT, "----Boundary%s", szRandomText); + + CMStringA szUrl(FORMAT, "/channels/%lld/messages", getId(p->hContact, DB_KEY_CHANNELID)); + AsyncHttpRequest *pReq = new AsyncHttpRequest(this, REQUEST_POST, szUrl, &CDiscordProto::OnReceiveFile); + pReq->AddHeader("Content-Type", CMStringA("multipart/form-data; boundary=" + szBoundary)); + pReq->AddHeader("Accept", "*/*"); + + szBoundary.Insert(0, "--"); + + CMStringA szBody; + szBody.Append(szBoundary + "\r\n"); + szBody.Append("Content-Disposition: form-data; name=\"content\"\r\n\r\n"); + szBody.Append(ptrA(mir_utf8encodeW(p->wszDescr))); + szBody.Append("\r\n"); + + szBody.Append(szBoundary + "\r\n"); + szBody.Append("Content-Disposition: form-data; name=\"tts\"\r\n\r\nfalse\r\n"); + + wchar_t *pFileName = wcsrchr(p->wszFileName.GetBuffer(), '\\'); + if (pFileName != nullptr) + pFileName++; + else + pFileName = p->wszFileName.GetBuffer(); + + szBody.Append(szBoundary + "\r\n"); + szBody.AppendFormat("Content-Disposition: form-data; name=\"file\"; filename=\"%s\"\r\n", ptrA(mir_utf8encodeW(pFileName)).get()); + szBody.AppendFormat("Content-Type: %S\r\n", ProtoGetAvatarMimeType(ProtoGetAvatarFileFormat(p->wszFileName))); + szBody.Append("\r\n"); + + size_t cbBytes = filelength(fileno(in)); + + szBoundary.Insert(0, "\r\n"); + szBoundary.Append("--\r\n"); + pReq->dataLength = int(szBody.GetLength() + szBoundary.GetLength() + cbBytes); + pReq->pData = (char*)mir_alloc(pReq->dataLength+1); + memcpy(pReq->pData, szBody.c_str(), szBody.GetLength()); + size_t cbRead = fread(pReq->pData + szBody.GetLength(), 1, cbBytes, in); + fclose(in); + if (cbBytes != cbRead) { + debugLogA("cannot read file %S: %d bytes read instead of %d", p->wszFileName.c_str(), cbRead, cbBytes); + delete pReq; + goto LBL_Error; + } + + memcpy(pReq->pData + szBody.GetLength() + cbBytes, szBoundary, szBoundary.GetLength()); + pReq->pUserInfo = p; + Push(pReq); + + ProtoBroadcastAck(p->hContact, ACKTYPE_FILE, ACKRESULT_CONNECTED, param); +} + +void CDiscordProto::OnReceiveFile(NETLIBHTTPREQUEST *pReply, AsyncHttpRequest *pReq) +{ + SendFileThreadParam *p = (SendFileThreadParam*)pReq->pUserInfo; + if (pReply->resultCode != 200) { + ProtoBroadcastAck(p->hContact, ACKTYPE_FILE, ACKRESULT_FAILED, p); + debugLogA("CDiscordProto::SendFile failed: %d", pReply->resultCode); + } + else { + ProtoBroadcastAck(p->hContact, ACKTYPE_FILE, ACKRESULT_SUCCESS, p); + debugLogA("CDiscordProto::SendFile succeeded"); + } + + delete p; +} + +HANDLE CDiscordProto::SendFile(MCONTACT hContact, const wchar_t *szDescription, wchar_t **ppszFiles) +{ + SnowFlake id = getId(hContact, DB_KEY_CHANNELID); + if (id == 0) + return nullptr; + + // we don't wanna block the main thread, right? + SendFileThreadParam *param = new SendFileThreadParam(hContact, ppszFiles[0], szDescription); + ForkThread(&CDiscordProto::SendFileThread, param); + return param; +} diff --git a/protocols/Discord/src/proto.h b/protocols/Discord/src/proto.h new file mode 100644 index 0000000000..bf3929fd55 --- /dev/null +++ b/protocols/Discord/src/proto.h @@ -0,0 +1,476 @@ +#pragma once + +#define EVENT_INCOMING_CALL 10001 +#define EVENT_CALL_FINISHED 10002 + +typedef __int64 SnowFlake; + +__forceinline int compareInt64(const SnowFlake i1, const SnowFlake i2) +{ + return (i1 == i2) ? 0 : (i1 < i2) ? -1 : 1; +} + +class CDiscordProto; +typedef void (CDiscordProto::*GatewayHandlerFunc)(const JSONNode&); + +struct AsyncHttpRequest : public MTHttpRequest<CDiscordProto> +{ + AsyncHttpRequest(CDiscordProto*, int iRequestType, LPCSTR szUrl, MTHttpRequestHandler pFunc, JSONNode *pNode = nullptr); + + int m_iErrorCode, m_iReqNum; + bool m_bMainSite; + MCONTACT hContact; +}; + +class JsonReply +{ + JSONNode *m_root = nullptr; + int m_errorCode = 0; + +public: + JsonReply(NETLIBHTTPREQUEST *); + ~JsonReply(); + + __forceinline int error() const { return m_errorCode; } + __forceinline JSONNode& data() const { return *m_root; } + __forceinline operator bool() const { return m_errorCode == 200; } +}; + +///////////////////////////////////////////////////////////////////////////////////////// + +struct CDiscordRole : public MZeroedObject +{ + SnowFlake id; + COLORREF color; + uint32_t permissions; + int position; + CMStringW wszName; +}; + +///////////////////////////////////////////////////////////////////////////////////////// + +struct COwnMessage +{ + SnowFlake nonce; + int reqId; + + COwnMessage(SnowFlake _id, int _reqId) : + nonce(_id), + reqId(_reqId) + {} +}; + +///////////////////////////////////////////////////////////////////////////////////////// + +enum CDiscordHistoryOp +{ + MSG_NOFILTER, MSG_AFTER, MSG_BEFORE +}; + +struct CDiscordUser : public MZeroedObject +{ + CDiscordUser(SnowFlake _id) : + id(_id) + {} + + ~CDiscordUser(); + + SnowFlake id; + MCONTACT hContact; + + SnowFlake channelId; + SnowFlake lastReadId, lastMsgId; + SnowFlake parentId; + bool bIsPrivate; + bool bIsGroup; + bool bSynced; + + struct CDiscordGuild *pGuild; + + CMStringW wszUsername, wszChannelName, wszTopic; + int iDiscriminator; +}; + +///////////////////////////////////////////////////////////////////////////////////////// + +struct CDiscordGuildMember : public MZeroedObject +{ + CDiscordGuildMember(SnowFlake id) : + userId(id) + {} + + ~CDiscordGuildMember() + {} + + SnowFlake userId; + CMStringW wszDiscordId, wszNick, wszRole; + int iStatus; +}; + +struct CDiscordGuild : public MZeroedObject +{ + CDiscordGuild(SnowFlake _id); + ~CDiscordGuild(); + + __forceinline CDiscordGuildMember* FindUser(SnowFlake userId) + { + return arChatUsers.find((CDiscordGuildMember *)&userId); + } + + __inline CMStringW GetCacheFile() const + { + return CMStringW(FORMAT, L"%s\\DiscordCache\\%lld.json", VARSW(L"%miranda_userdata%").get(), id); + } + + SnowFlake id, ownerId; + CMStringW wszName; + MCONTACT hContact; + MGROUP groupId; + bool bSynced = false; + LIST<CDiscordUser> arChannels; + + SESSION_INFO *pParentSi; + OBJLIST<CDiscordGuildMember> arChatUsers; + OBJLIST<CDiscordRole> arRoles; // guild roles + + void LoadFromFile(); + void SaveToFile(); +}; + +struct CDiscordVoiceCall +{ + CMStringA szId; + SnowFlake channelId; + time_t startTime; +}; + +///////////////////////////////////////////////////////////////////////////////////////// + +#define OPCODE_DISPATCH 0 +#define OPCODE_HEARTBEAT 1 +#define OPCODE_IDENTIFY 2 +#define OPCODE_STATUS_UPDATE 3 +#define OPCODE_VOICE_UPDATE 4 +#define OPCODE_VOICE_PING 5 +#define OPCODE_RESUME 6 +#define OPCODE_RECONNECT 7 +#define OPCODE_REQUEST_MEMBERS 8 +#define OPCODE_INVALID_SESSION 9 +#define OPCODE_HELLO 10 +#define OPCODE_HEARTBEAT_ACK 11 +#define OPCODE_REQUEST_SYNC 12 +#define OPCODE_REQUEST_SYNC_GROUP 13 +#define OPCODE_REQUEST_SYNC_CHANNEL 14 + +class CDiscordProto : public PROTO<CDiscordProto> +{ + friend struct AsyncHttpRequest; + friend class CDiscardAccountOptions; + + class CDiscordProtoImpl + { + friend class CDiscordProto; + CDiscordProto &m_proto; + + CTimer m_heartBeat, m_markRead; + void OnHeartBeat(CTimer *) { + m_proto.GatewaySendHeartbeat(); + } + + void OnMarkRead(CTimer *pTimer) { + m_proto.SendMarkRead(); + pTimer->Stop(); + } + + CDiscordProtoImpl(CDiscordProto &pro) : + m_proto(pro), + m_markRead(Miranda_GetSystemWindow(), UINT_PTR(this)), + m_heartBeat(Miranda_GetSystemWindow(), UINT_PTR(this) + 1) + { + m_markRead.OnEvent = Callback(this, &CDiscordProtoImpl::OnMarkRead); + m_heartBeat.OnEvent = Callback(this, &CDiscordProtoImpl::OnHeartBeat); + } + } m_impl; + + ////////////////////////////////////////////////////////////////////////////////////// + // threads + + void __cdecl SendFileThread(void*); + void __cdecl ServerThread(void*); + void __cdecl SearchThread(void *param); + void __cdecl BatchChatCreate(void* param); + void __cdecl GetAwayMsgThread(void *param); + + ////////////////////////////////////////////////////////////////////////////////////// + // session control + + void ConnectionFailed(int iReason); + void ShutdownSession(void); + + wchar_t *m_wszStatusMsg[MAX_STATUS_COUNT]; + + ptrA m_szAccessToken, m_szTempToken; + + mir_cs m_csHttpQueue; + HANDLE m_evRequestsQueue; + LIST<AsyncHttpRequest> m_arHttpQueue; + + void ExecuteRequest(AsyncHttpRequest *pReq); + void Push(AsyncHttpRequest *pReq, int iTimeout = 10000); + void SaveToken(const JSONNode &data); + + HANDLE m_hWorkerThread; // worker thread handle + HNETLIBCONN m_hAPIConnection; // working connection + + bool + m_bOnline, // protocol is online + m_bTerminated; // Miranda's going down + + ////////////////////////////////////////////////////////////////////////////////////// + // gateway + + CMStringA + m_szGateway, // gateway url + m_szGatewaySessionId, // current session id + m_szCookie, // cookie used for all http queries + m_szWSCookie; // cookie used for establishing websocket connection + + HNETLIBUSER m_hGatewayNetlibUser; // the separate netlib user handle for gateways + HNETLIBCONN m_hGatewayConnection; // gateway connection + + void __cdecl GatewayThread(void*); + bool GatewayThreadWorker(void); + + bool GatewaySend(const JSONNode &pNode); + bool GatewayProcess(const JSONNode &pNode); + + void GatewaySendGuildInfo(CDiscordGuild *pGuild); + void GatewaySendHeartbeat(void); + void GatewaySendIdentify(void); + void GatewaySendResume(void); + bool GatewaySendStatus(int iStatus, const wchar_t *pwszStatusText); + + GatewayHandlerFunc GetHandler(const wchar_t*); + + int m_iHartbeatInterval; // in milliseconds + int m_iGatewaySeq; // gateway sequence number + + ////////////////////////////////////////////////////////////////////////////////////// + // options + + CMOption<wchar_t*> m_wszEmail; // my own email + CMOption<wchar_t*> m_wszDefaultGroup; // clist group to store contacts + CMOption<uint8_t> m_bUseGroupchats; // Shall we connect Guilds at all? + CMOption<uint8_t> m_bHideGroupchats; // Do not open chat windows on creation + CMOption<uint8_t> m_bUseGuildGroups; // use special subgroups for guilds + CMOption<uint8_t> m_bSyncDeleteMsgs; // delete messages from Miranda if they are deleted at the server + + ////////////////////////////////////////////////////////////////////////////////////// + // common data + + SnowFlake m_ownId; + + mir_cs csMarkReadQueue; + LIST<CDiscordUser> arMarkReadQueue; + + OBJLIST<CDiscordUser> arUsers; + OBJLIST<COwnMessage> arOwnMessages; + OBJLIST<CDiscordVoiceCall> arVoiceCalls; + + CDiscordUser* FindUser(SnowFlake id); + CDiscordUser* FindUser(const wchar_t *pwszUsername, int iDiscriminator); + CDiscordUser* FindUserByChannel(SnowFlake channelId); + + void PreparePrivateChannel(const JSONNode &); + CDiscordUser* PrepareUser(const JSONNode &); + + ////////////////////////////////////////////////////////////////////////////////////// + // menu items + + void InitMenus(void); + + int __cdecl OnMenuPrebuild(WPARAM, LPARAM); + + INT_PTR __cdecl OnMenuCopyId(WPARAM, LPARAM); + INT_PTR __cdecl OnMenuCreateChannel(WPARAM, LPARAM); + INT_PTR __cdecl OnMenuJoinGuild(WPARAM, LPARAM); + INT_PTR __cdecl OnMenuLeaveGuild(WPARAM, LPARAM); + INT_PTR __cdecl OnMenuLoadHistory(WPARAM, LPARAM); + INT_PTR __cdecl OnMenuToggleSync(WPARAM, LPARAM); + + HGENMENU m_hMenuLeaveGuild, m_hMenuCreateChannel, m_hMenuToggleSync; + + ////////////////////////////////////////////////////////////////////////////////////// + // guilds + + OBJLIST<CDiscordGuild> arGuilds; + + __forceinline CDiscordGuild* FindGuild(SnowFlake id) const + { + return arGuilds.find((CDiscordGuild*)&id); + } + + void AddGuildUser(CDiscordGuild *guild, const CDiscordGuildMember &pUser); + void ProcessGuild(const JSONNode &json); + void ProcessPresence(const JSONNode &json); + void ProcessRole(CDiscordGuild *guild, const JSONNode &json); + void ProcessType(CDiscordUser *pUser, const JSONNode &json); + + CDiscordUser* ProcessGuildChannel(CDiscordGuild *guild, const JSONNode &json); + CDiscordGuildMember* ProcessGuildUser(CDiscordGuild *pGuild, const JSONNode &json, bool *bNew = nullptr); + + ////////////////////////////////////////////////////////////////////////////////////// + // group chats + + int __cdecl GroupchatEventHook(WPARAM, LPARAM); + int __cdecl GroupchatMenuHook(WPARAM, LPARAM); + + void Chat_SendPrivateMessage(GCHOOK *gch); + void Chat_ProcessLogMenu(GCHOOK *gch); + void Chat_ProcessNickMenu(GCHOOK* gch); + + void CreateChat(CDiscordGuild *pGuild, CDiscordUser *pUser); + void ProcessChatUser(CDiscordUser *pChat, const CMStringW &wszUserId, const JSONNode &pRoot); + void ParseSpecialChars(SESSION_INFO *si, CMStringW &str); + + ////////////////////////////////////////////////////////////////////////////////////// + // misc methods + + SnowFlake getId(const char *szName); + SnowFlake getId(MCONTACT hContact, const char *szName); + + void setId(const char *szName, SnowFlake iValue); + void setId(MCONTACT hContact, const char *szName, SnowFlake iValue); + +public: + CDiscordProto(const char*,const wchar_t*); + ~CDiscordProto(); + + ////////////////////////////////////////////////////////////////////////////////////// + // PROTO_INTERFACE + + INT_PTR GetCaps(int, MCONTACT = 0) override; + + HWND CreateExtendedSearchUI(HWND owner) override; + HWND SearchAdvanced(HWND owner) override; + + HANDLE SearchBasic(const wchar_t *id) override; + MCONTACT AddToList(int flags, PROTOSEARCHRESULT *psr) override; + MCONTACT AddToListByEvent(int flags, int, MEVENT hDbEvent) override; + + int AuthRecv(MCONTACT, PROTORECVEVENT *pre) override; + int Authorize(MEVENT hDbEvent) override; + int AuthDeny(MEVENT hDbEvent, const wchar_t* szReason) override; + int AuthRequest(MCONTACT hContact, const wchar_t*) override; + + HANDLE GetAwayMsg(MCONTACT hContact) override; + int SetAwayMsg(int iStatus, const wchar_t *msg) override; + + int SendMsg(MCONTACT hContact, int flags, const char *pszSrc) override; + + HANDLE SendFile(MCONTACT hContact, const wchar_t *szDescription, wchar_t **ppszFiles) override; + + int UserIsTyping(MCONTACT hContact, int type) override; + + int SetStatus(int iNewStatus) override; + + void OnBuildProtoMenu() override; + void OnContactDeleted(MCONTACT) override; + void OnModulesLoaded() override; + void OnShutdown() override; + + ////////////////////////////////////////////////////////////////////////////////////// + // Services + + INT_PTR __cdecl RequestFriendship(WPARAM, LPARAM); + INT_PTR __cdecl SvcCreateAccMgrUI(WPARAM, LPARAM); + + INT_PTR __cdecl GetAvatarCaps(WPARAM, LPARAM); + INT_PTR __cdecl GetAvatarInfo(WPARAM, LPARAM); + INT_PTR __cdecl GetMyAvatar(WPARAM, LPARAM); + INT_PTR __cdecl SetMyAvatar(WPARAM, LPARAM); + + INT_PTR __cdecl VoiceCaps(WPARAM, LPARAM); + + ////////////////////////////////////////////////////////////////////////////////////// + // Events + + int __cdecl OnOptionsInit(WPARAM, LPARAM); + int __cdecl OnAccountChanged(WPARAM, LPARAM); + int __cdecl OnDbEventRead(WPARAM, LPARAM); + + int __cdecl OnVoiceState(WPARAM, LPARAM); + + ////////////////////////////////////////////////////////////////////////////////////// + // dispatch commands + + void OnCommandCallCreated(const JSONNode &json); + void OnCommandCallDeleted(const JSONNode &json); + void OnCommandCallUpdated(const JSONNode &json); + void OnCommandChannelCreated(const JSONNode &json); + void OnCommandChannelDeleted(const JSONNode &json); + void OnCommandChannelUpdated(const JSONNode &json); + void OnCommandGuildCreated(const JSONNode &json); + void OnCommandGuildDeleted(const JSONNode &json); + void OnCommandGuildMemberAdded(const JSONNode &json); + void OnCommandGuildMemberListUpdate(const JSONNode &json); + void OnCommandGuildMemberRemoved(const JSONNode &json); + void OnCommandGuildMemberUpdated(const JSONNode &json); + void OnCommandFriendAdded(const JSONNode &json); + void OnCommandFriendRemoved(const JSONNode &json); + void OnCommandMessage(const JSONNode&, bool); + void OnCommandMessageCreate(const JSONNode &json); + void OnCommandMessageDelete(const JSONNode &json); + void OnCommandMessageUpdate(const JSONNode &json); + void OnCommandMessageAck(const JSONNode &json); + void OnCommandPresence(const JSONNode &json); + void OnCommandReady(const JSONNode &json); + void OnCommandRoleCreated(const JSONNode &json); + void OnCommandRoleDeleted(const JSONNode &json); + void OnCommandTyping(const JSONNode &json); + void OnCommandUserUpdate(const JSONNode &json); + void OnCommandUserSettingsUpdate(const JSONNode &json); + + void OnLoggedIn(); + void OnLoggedOut(); + + void OnReceiveCreateChannel(NETLIBHTTPREQUEST*, AsyncHttpRequest*); + void OnReceiveFile(NETLIBHTTPREQUEST*, AsyncHttpRequest*); + void OnReceiveGateway(NETLIBHTTPREQUEST*, AsyncHttpRequest*); + void OnReceiveMarkRead(NETLIBHTTPREQUEST *, AsyncHttpRequest *); + void OnReceiveMessageAck(NETLIBHTTPREQUEST*, AsyncHttpRequest*); + void OnReceiveToken(NETLIBHTTPREQUEST *, AsyncHttpRequest *); + void OnReceiveUserinfo(NETLIBHTTPREQUEST *, AsyncHttpRequest *); + + void RetrieveMyInfo(); + void OnReceiveMyInfo(NETLIBHTTPREQUEST*, AsyncHttpRequest*); + + void RetrieveHistory(CDiscordUser *pUser, CDiscordHistoryOp iOp = MSG_NOFILTER, SnowFlake msgid = 0, int iLimit = 50); + void OnReceiveHistory(NETLIBHTTPREQUEST*, AsyncHttpRequest*); + + bool RetrieveAvatar(MCONTACT hContact); + void OnReceiveAvatar(NETLIBHTTPREQUEST*, AsyncHttpRequest*); + + void OnSendMsg(NETLIBHTTPREQUEST*, AsyncHttpRequest*); + + ////////////////////////////////////////////////////////////////////////////////////// + // Misc + + void SendMarkRead(void); + void SetServerStatus(int iStatus); + void RemoveFriend(SnowFlake id); + + CMStringW GetAvatarFilename(MCONTACT hContact); + void CheckAvatarChange(MCONTACT hContact, const CMStringW &wszNewHash); +}; + +///////////////////////////////////////////////////////////////////////////////////////// + +struct CMPlugin : public ACCPROTOPLUGIN<CDiscordProto> +{ + CMPlugin(); + + bool bVoiceService = false; + + int Load() override; +}; diff --git a/protocols/Discord/src/resource.h b/protocols/Discord/src/resource.h new file mode 100644 index 0000000000..d0326e6857 --- /dev/null +++ b/protocols/Discord/src/resource.h @@ -0,0 +1,30 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by w:\miranda-ng\protocols\Discord\res\discord.rc +// +#define IDI_MAIN 101 +#define IDI_GROUPCHAT 102 +#define IDD_OPTIONS_ACCOUNT 103 +#define IDD_EXTSEARCH 104 +#define IDD_OPTIONS_ACCMGR 105 +#define IDI_VOICE_CALL 106 +#define IDI_VOICE_ENDED 107 +#define IDC_PASSWORD 1001 +#define IDC_USERNAME 1002 +#define IDC_GROUP 1003 +#define IDC_NICK 1004 +#define IDC_HIDECHATS 1005 +#define IDC_USEGROUPS 1006 +#define IDC_USEGUILDS 1007 +#define IDC_DELETE_MSGS 1009 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 104 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1008 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/protocols/Discord/src/server.cpp b/protocols/Discord/src/server.cpp new file mode 100644 index 0000000000..cc6dfe2280 --- /dev/null +++ b/protocols/Discord/src/server.cpp @@ -0,0 +1,307 @@ +/* +Copyright © 2016-22 Miranda NG team + +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, see <http://www.gnu.org/licenses/>. +*/ + +#include "stdafx.h" + +///////////////////////////////////////////////////////////////////////////////////////// +// removes a friend from the server + +void CDiscordProto::RemoveFriend(SnowFlake id) +{ + Push(new AsyncHttpRequest(this, REQUEST_DELETE, CMStringA(FORMAT, "/users/@me/relationships/%lld", id), nullptr)); +} + +///////////////////////////////////////////////////////////////////////////////////////// +// retrieves server history + +void CDiscordProto::RetrieveHistory(CDiscordUser *pUser, CDiscordHistoryOp iOp, SnowFlake msgid, int iLimit) +{ + if (!pUser->hContact || getByte(pUser->hContact, DB_KEY_DONT_FETCH)) + return; + + CMStringA szUrl(FORMAT, "/channels/%lld/messages", pUser->channelId); + AsyncHttpRequest *pReq = new AsyncHttpRequest(this, REQUEST_GET, szUrl, &CDiscordProto::OnReceiveHistory); + pReq << INT_PARAM("limit", iLimit); + + if (msgid) { + switch (iOp) { + case MSG_AFTER: + pReq << INT64_PARAM("after", msgid); break; + case MSG_BEFORE: + pReq << INT64_PARAM("before", msgid); break; + } + } + pReq->pUserInfo = pUser; + Push(pReq); +} + +static int compareMsgHistory(const JSONNode *p1, const JSONNode *p2) +{ + return wcscmp((*p1)["id"].as_mstring(), (*p2)["id"].as_mstring()); +} + +void CDiscordProto::OnReceiveHistory(NETLIBHTTPREQUEST *pReply, AsyncHttpRequest *pReq) +{ + CDiscordUser *pUser = (CDiscordUser*)pReq->pUserInfo; + + JsonReply root(pReply); + if (!root) { + if (root.error() == 403) // forbidden, don't try to read it anymore + setByte(pUser->hContact, DB_KEY_DONT_FETCH, true); + return; + } + + SESSION_INFO *si = nullptr; + if (!pUser->bIsPrivate) { + si = g_chatApi.SM_FindSession(pUser->wszUsername, m_szModuleName); + if (si == nullptr) { + debugLogA("message to unknown channel %lld ignored", pUser->channelId); + return; + } + } + + SnowFlake lastId = getId(pUser->hContact, DB_KEY_LASTMSGID); // as stored in a database + + LIST<JSONNode> arNodes(10, compareMsgHistory); + int iNumMessages = 0; + for (auto &it : root.data()) { + arNodes.insert(&it); + iNumMessages++; + } + + for (auto &it : arNodes) { + auto &pNode = *it; + CMStringW wszText = PrepareMessageText(pNode); + CMStringW wszUserId = pNode["author"]["id"].as_mstring(); + SnowFlake msgid = ::getId(pNode["id"]); + SnowFlake authorid = _wtoi64(wszUserId); + uint32_t dwTimeStamp = StringToDate(pNode["timestamp"].as_mstring()); + + if (pUser->bIsPrivate) { + DBEVENTINFO dbei = {}; + dbei.szModule = m_szModuleName; + dbei.flags = DBEF_UTF; + dbei.eventType = EVENTTYPE_MESSAGE; + + if (authorid == m_ownId) + dbei.flags |= DBEF_SENT; + else + dbei.flags &= ~DBEF_SENT; + + if (msgid <= pUser->lastReadId) + dbei.flags |= DBEF_READ; + else + dbei.flags &= ~DBEF_READ; + + ptrA szBody(mir_utf8encodeW(wszText)); + dbei.timestamp = dwTimeStamp; + dbei.pBlob = (uint8_t*)szBody.get(); + dbei.cbBlob = (uint32_t)mir_strlen(szBody); + + bool bSucceeded = false; + char szMsgId[100]; + _i64toa_s(msgid, szMsgId, _countof(szMsgId), 10); + MEVENT hDbEvent = db_event_getById(m_szModuleName, szMsgId); + if (hDbEvent != 0) + bSucceeded = 0 == db_event_edit(pUser->hContact, hDbEvent, &dbei); + + if (!bSucceeded) { + dbei.szId = szMsgId; + db_event_add(pUser->hContact, &dbei); + } + } + else { + ProcessChatUser(pUser, wszUserId, pNode); + + ParseSpecialChars(si, wszText); + + GCEVENT gce = { m_szModuleName, 0, GC_EVENT_MESSAGE }; + gce.pszID.w = pUser->wszUsername; + gce.dwFlags = GCEF_ADDTOLOG; + gce.pszUID.w = wszUserId; + gce.pszText.w = wszText; + gce.time = dwTimeStamp; + gce.bIsMe = authorid == m_ownId; + Chat_Event(&gce); + } + + if (lastId < msgid) + lastId = msgid; + } + + setId(pUser->hContact, DB_KEY_LASTMSGID, lastId); + + // if we fetched 99 messages, but have smth more to go, continue fetching + if (iNumMessages == 99 && lastId < pUser->lastMsgId) + RetrieveHistory(pUser, MSG_AFTER, lastId, 99); +} + +///////////////////////////////////////////////////////////////////////////////////////// +// retrieves user info + +void CDiscordProto::RetrieveMyInfo() +{ + Push(new AsyncHttpRequest(this, REQUEST_GET, "/users/@me", &CDiscordProto::OnReceiveMyInfo)); +} + +void CDiscordProto::OnReceiveMyInfo(NETLIBHTTPREQUEST *pReply, AsyncHttpRequest*) +{ + JsonReply root(pReply); + if (!root) { + ConnectionFailed(LOGINERR_WRONGPASSWORD); + return; + } + + auto &data = root.data(); + SnowFlake id = ::getId(data["id"]); + setId(0, DB_KEY_ID, id); + + setByte(0, DB_KEY_MFA, data["mfa_enabled"].as_bool()); + setDword(0, DB_KEY_DISCR, _wtoi(data["discriminator"].as_mstring())); + setWString(0, DB_KEY_NICK, data["username"].as_mstring()); + m_wszEmail = data["email"].as_mstring(); + + m_ownId = id; + + m_szCookie.Empty(); + for (int i=0; i < pReply->headersCount; i++) { + if (!mir_strcmpi(pReply->headers[i].szName, "Set-Cookie")) { + char *p = strchr(pReply->headers[i].szValue, ';'); + if (p) *p = 0; + if (!m_szCookie.IsEmpty()) + m_szCookie.Append("; "); + + m_szCookie.Append(pReply->headers[i].szValue); + } + } + + // launch gateway thread + if (m_szGateway.IsEmpty()) + Push(new AsyncHttpRequest(this, REQUEST_GET, "/gateway", &CDiscordProto::OnReceiveGateway)); + else + ForkThread(&CDiscordProto::GatewayThread, nullptr); + + CheckAvatarChange(0, data["avatar"].as_mstring()); +} + +///////////////////////////////////////////////////////////////////////////////////////// +// finds a gateway address + +void CDiscordProto::OnReceiveGateway(NETLIBHTTPREQUEST *pReply, AsyncHttpRequest*) +{ + JsonReply root(pReply); + if (!root) { + ShutdownSession(); + return; + } + + auto &data = root.data(); + m_szGateway = data["url"].as_mstring(); + ForkThread(&CDiscordProto::GatewayThread, nullptr); +} + +///////////////////////////////////////////////////////////////////////////////////////// + +void CDiscordProto::SetServerStatus(int iStatus) +{ + if (GatewaySendStatus(iStatus, nullptr)) { + int iOldStatus = m_iStatus; m_iStatus = iStatus; + ProtoBroadcastAck(0, ACKTYPE_STATUS, ACKRESULT_SUCCESS, (HANDLE)iOldStatus, m_iStatus); + } +} + +///////////////////////////////////////////////////////////////////////////////////////// +// channels + +void CDiscordProto::OnReceiveCreateChannel(NETLIBHTTPREQUEST *pReply, AsyncHttpRequest*) +{ + JsonReply root(pReply); + if (root) + OnCommandChannelCreated(root.data()); +} + +///////////////////////////////////////////////////////////////////////////////////////// + +void CDiscordProto::OnReceiveMessageAck(NETLIBHTTPREQUEST *pReply, AsyncHttpRequest*) +{ + JsonReply root(pReply); + if (!root) + return; + + auto &data = root.data(); + CMStringW wszToken(data["token"].as_mstring()); + if (!wszToken.IsEmpty()) { + JSONNode props; props.set_name("properties"); + JSONNode reply; reply << props; + reply << CHAR_PARAM("event", "ack_messages") << WCHAR_PARAM("token", data["token"].as_mstring()); + Push(new AsyncHttpRequest(this, REQUEST_POST, "/track", nullptr, &reply)); + } +} + +///////////////////////////////////////////////////////////////////////////////////////// + +#define RECAPTCHA_API_KEY "6Lef5iQTAAAAAKeIvIY-DeexoO3gj7ryl9rLMEnn" +#define RECAPTCHA_SITE_URL "https://discord.com" + +void CDiscordProto::OnReceiveToken(NETLIBHTTPREQUEST *pReply, AsyncHttpRequest*) +{ + if (pReply->resultCode != 200) { + JSONNode root = JSONNode::parse(pReply->pData); + if (root) { + const JSONNode &captcha = root["captcha_key"].as_array(); + if (captcha) { + for (auto &it : captcha) { + if (it.as_mstring() == "captcha-required") { + MessageBoxW(NULL, TranslateT("The server requires you to enter the captcha. Miranda will redirect you to a browser now"), L"Discord", MB_OK | MB_ICONINFORMATION); + Utils_OpenUrl("https://discord.com/app"); + } + } + } + + for (auto &err: root["errors"]["email"]["_errors"]) { + CMStringW code(err["code"].as_mstring()); + CMStringW message(err["message"].as_mstring()); + if (!code.IsEmpty() || !message.IsEmpty()) { + POPUPDATAW popup; + popup.lchIcon = IcoLib_GetIconByHandle(Skin_GetIconHandle(SKINICON_ERROR), true); + wcscpy_s(popup.lpwzContactName, m_tszUserName); + mir_snwprintf(popup.lpwzText, TranslateT("Connection failed.\n%s (%s)."), message.c_str(), code.c_str()); + PUAddPopupW(&popup); + } + } + } + ConnectionFailed(LOGINERR_WRONGPASSWORD); + return; + } + + JsonReply root(pReply); + if (!root) { + ConnectionFailed(LOGINERR_NOSERVER); + return; + } + + auto &data = root.data(); + CMStringA szToken = data["token"].as_mstring(); + if (szToken.IsEmpty()) { + debugLogA("Strange empty token received, exiting"); + return; + } + + m_szAccessToken = szToken.Detach(); + setString("AccessToken", m_szAccessToken); + RetrieveMyInfo(); +} diff --git a/protocols/Discord/src/stdafx.cxx b/protocols/Discord/src/stdafx.cxx new file mode 100644 index 0000000000..4b7f53343f --- /dev/null +++ b/protocols/Discord/src/stdafx.cxx @@ -0,0 +1,18 @@ +/* +Copyright © 2016-22 Miranda NG team + +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, see <http://www.gnu.org/licenses/>. +*/ + +#include "stdafx.h"
\ No newline at end of file diff --git a/protocols/Discord/src/stdafx.h b/protocols/Discord/src/stdafx.h new file mode 100644 index 0000000000..6cba015cc3 --- /dev/null +++ b/protocols/Discord/src/stdafx.h @@ -0,0 +1,80 @@ +// stdafx.h : include file for standard system include files, +// or project specific include files that are used frequently, but +// are changed infrequently +// + +#pragma once + +#include <Windows.h> +#include <Shlwapi.h> +#include <Wincrypt.h> + +#include <malloc.h> +#include <stdio.h> +#include <io.h> +#include <fcntl.h> +#include <direct.h> +#include <time.h> + +#include <vector> + +#include "resource.h" + +#include <m_system.h> +#include <newpluginapi.h> +#include <m_avatars.h> +#include <m_chat_int.h> +#include <m_clist.h> +#include <m_contacts.h> +#include <m_database.h> +#include <m_folders.h> +#include <m_gui.h> +#include <m_history.h> +#include <m_hotkeys.h> +#include <m_icolib.h> +#include <m_json.h> +#include <m_langpack.h> +#include <m_message.h> +#include <m_netlib.h> +#include <m_options.h> +#include <m_popup.h> +#include <m_protocols.h> +#include <m_protosvc.h> +#include <m_protoint.h> +#include <m_skin.h> +#include <m_srmm_int.h> +#include <m_userinfo.h> +#include <m_utils.h> +#include <m_voice.h> +#include <m_voiceservice.h> + +#include "../../libs/zlib/src/zlib.h" + +extern IconItem g_iconList[]; + +#define DB_KEY_ID "id" +#define DB_KEY_PASSWORD "Password" +#define DB_KEY_DISCR "Discriminator" +#define DB_KEY_MFA "MfaEnabled" +#define DB_KEY_NICK "Nick" +#define DB_KEY_AVHASH "AvatarHash" +#define DB_KEY_CHANNELID "ChannelID" +#define DB_KEY_LASTMSGID "LastMessageID" +#define DB_KEY_REQAUTH "ReqAuth" +#define DB_KEY_DONT_FETCH "DontFetch" + +#define DB_KEYVAL_GROUP L"Discord" + +#include "version.h" +#include "proto.h" + +///////////////////////////////////////////////////////////////////////////////////////// + +void BuildStatusList(const CDiscordGuild *pGuild, SESSION_INFO *si); + +void CopyId(const CMStringW &nick); +SnowFlake getId(const JSONNode &pNode); +CMStringW PrepareMessageText(const JSONNode &pRoot); +int StrToStatus(const CMStringW &str); +time_t StringToDate(const CMStringW &str); +int SerialNext(void); diff --git a/protocols/Discord/src/utils.cpp b/protocols/Discord/src/utils.cpp new file mode 100644 index 0000000000..ac40407c69 --- /dev/null +++ b/protocols/Discord/src/utils.cpp @@ -0,0 +1,376 @@ +/* +Copyright © 2016-22 Miranda NG team + +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, see <http://www.gnu.org/licenses/>. +*/ + +#include "stdafx.h" + +int StrToStatus(const CMStringW &str) +{ + if (str == L"idle") + return ID_STATUS_NA; + if (str == L"dnd") + return ID_STATUS_DND; + if (str == L"online") + return ID_STATUS_ONLINE; + if (str == L"offline") + return ID_STATUS_OFFLINE; + return 0; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +time_t StringToDate(const CMStringW &str) +{ + struct tm T = { 0 }; + int boo; + if (swscanf(str, L"%04d-%02d-%02dT%02d:%02d:%02d.%d", &T.tm_year, &T.tm_mon, &T.tm_mday, &T.tm_hour, &T.tm_min, &T.tm_sec, &boo) != 7) + return time(0); + + T.tm_year -= 1900; + T.tm_mon--; + time_t t = mktime(&T); + + _tzset(); + t -= _timezone; + return (t >= 0) ? t : 0; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +static LONG volatile g_counter = 1; + +int SerialNext() +{ + return InterlockedIncrement(&g_counter); +} + +///////////////////////////////////////////////////////////////////////////////////////// + +SnowFlake getId(const JSONNode &pNode) +{ + return _wtoi64(pNode.as_mstring()); +} + +SnowFlake CDiscordProto::getId(const char *szSetting) +{ + DBVARIANT dbv; + dbv.type = DBVT_BLOB; + if (db_get(0, m_szModuleName, szSetting, &dbv)) + return 0; + + SnowFlake result = (dbv.cpbVal == sizeof(SnowFlake)) ? *(SnowFlake*)dbv.pbVal : 0; + db_free(&dbv); + return result; +} + +SnowFlake CDiscordProto::getId(MCONTACT hContact, const char *szSetting) +{ + DBVARIANT dbv; + dbv.type = DBVT_BLOB; + if (db_get(hContact, m_szModuleName, szSetting, &dbv)) + return 0; + + SnowFlake result = (dbv.cpbVal == sizeof(SnowFlake)) ? *(SnowFlake*)dbv.pbVal : 0; + db_free(&dbv); + return result; +} + +void CDiscordProto::setId(const char *szSetting, SnowFlake iValue) +{ + SnowFlake oldVal = getId(szSetting); + if (oldVal != iValue) + db_set_blob(0, m_szModuleName, szSetting, &iValue, sizeof(iValue)); +} + +void CDiscordProto::setId(MCONTACT hContact, const char *szSetting, SnowFlake iValue) +{ + SnowFlake oldVal = getId(hContact, szSetting); + if (oldVal != iValue) + db_set_blob(hContact, m_szModuleName, szSetting, &iValue, sizeof(iValue)); +} + +///////////////////////////////////////////////////////////////////////////////////////// + +void CopyId(const CMStringW &nick) +{ + if (!OpenClipboard(nullptr)) + return; + + EmptyClipboard(); + + int length = nick.GetLength() + 1; + if (HGLOBAL hMemory = GlobalAlloc(GMEM_FIXED, length * sizeof(wchar_t))) { + mir_wstrncpy((wchar_t*)GlobalLock(hMemory), nick, length); + GlobalUnlock(hMemory); + SetClipboardData(CF_UNICODETEXT, hMemory); + } + CloseClipboard(); +} + +///////////////////////////////////////////////////////////////////////////////////////// + +static CDiscordUser *g_myUser = new CDiscordUser(0); + +CDiscordUser* CDiscordProto::FindUser(SnowFlake id) +{ + return arUsers.find((CDiscordUser*)&id); +} + +CDiscordUser* CDiscordProto::FindUser(const wchar_t *pwszUsername, int iDiscriminator) +{ + for (auto &p : arUsers) + if (p->wszUsername == pwszUsername && p->iDiscriminator == iDiscriminator) + return p; + + return nullptr; +} + +CDiscordUser* CDiscordProto::FindUserByChannel(SnowFlake channelId) +{ + for (auto &p : arUsers) + if (p->channelId == channelId) + return p; + + return nullptr; +} + +///////////////////////////////////////////////////////////////////////////////////////// +// Common JSON processing routines + +void CDiscordProto::PreparePrivateChannel(const JSONNode &root) +{ + CDiscordUser *pUser = nullptr; + + CMStringW wszChannelId = root["id"].as_mstring(); + SnowFlake channelId = _wtoi64(wszChannelId); + + int type = root["type"].as_int(); + switch (type) { + case 1: // single channel + for (auto &it : root["recipients"]) + pUser = PrepareUser(it); + if (pUser == nullptr) { + debugLogA("Invalid recipients list, exiting"); + return; + } + break; + + case 3: // private groupchat + if ((pUser = FindUserByChannel(channelId)) == nullptr) { + pUser = new CDiscordUser(channelId); + arUsers.insert(pUser); + } + pUser->bIsGroup = true; + pUser->wszUsername = wszChannelId; + pUser->wszChannelName = root["name"].as_mstring(); + { + SESSION_INFO *si = Chat_NewSession(GCW_CHATROOM, m_szModuleName, pUser->wszUsername, pUser->wszChannelName); + pUser->hContact = si->hContact; + + Chat_AddGroup(si, LPGENW("Owners")); + Chat_AddGroup(si, LPGENW("Participants")); + + SnowFlake ownerId = _wtoi64(root["owner_id"].as_mstring()); + + GCEVENT gce = { m_szModuleName, 0, GC_EVENT_JOIN }; + gce.pszID.w = pUser->wszUsername; + for (auto &it : root["recipients"]) { + CMStringW wszId = it["id"].as_mstring(); + CMStringW wszNick = it["nick"].as_mstring(); + if (wszNick.IsEmpty()) + wszNick = it["username"].as_mstring() + L"#" + it["discriminator"].as_mstring(); + + gce.pszUID.w = wszId; + gce.pszNick.w = wszNick; + gce.pszStatus.w = (_wtoi64(wszId) == ownerId) ? L"Owners" : L"Participants"; + Chat_Event(&gce); + } + + CMStringW wszId(FORMAT, L"%lld", getId(DB_KEY_ID)); + CMStringW wszNick(FORMAT, L"%s#%d", getMStringW(DB_KEY_NICK).c_str(), getDword(DB_KEY_DISCR)); + gce.bIsMe = true; + gce.pszUID.w = wszId; + gce.pszNick.w = wszNick; + gce.pszStatus.w = (_wtoi64(wszId) == ownerId) ? L"Owners" : L"Participants"; + Chat_Event(&gce); + + Chat_Control(m_szModuleName, pUser->wszUsername, m_bHideGroupchats ? WINDOW_HIDDEN : SESSION_INITDONE); + Chat_Control(m_szModuleName, pUser->wszUsername, SESSION_ONLINE); + } + break; + + default: + debugLogA("Invalid channel type: %d, exiting", type); + return; + } + + pUser->channelId = channelId; + pUser->lastMsgId = ::getId(root["last_message_id"]); + pUser->bIsPrivate = true; + + setId(pUser->hContact, DB_KEY_CHANNELID, pUser->channelId); + + SnowFlake oldMsgId = getId(pUser->hContact, DB_KEY_LASTMSGID); + if (pUser->lastMsgId > oldMsgId) + RetrieveHistory(pUser, MSG_AFTER, oldMsgId, 99); +} + +CDiscordUser* CDiscordProto::PrepareUser(const JSONNode &user) +{ + SnowFlake id = ::getId(user["id"]); + if (id == m_ownId) + return g_myUser; + + int iDiscriminator = _wtoi(user["discriminator"].as_mstring()); + CMStringW username = user["username"].as_mstring(); + + CDiscordUser *pUser = FindUser(id); + if (pUser == nullptr) { + MCONTACT tmp = INVALID_CONTACT_ID; + + // no user found by userid, try to find him via username+discriminator + pUser = FindUser(username, iDiscriminator); + if (pUser != nullptr) { + // if found, remove the object from list to resort it (its userid==0) + if (pUser->hContact != 0) + tmp = pUser->hContact; + arUsers.remove(pUser); + } + pUser = new CDiscordUser(id); + pUser->wszUsername = username; + pUser->iDiscriminator = iDiscriminator; + if (tmp != INVALID_CONTACT_ID) { + // if we previously had a recently added contact without userid, write it down + pUser->hContact = tmp; + setId(pUser->hContact, DB_KEY_ID, id); + } + arUsers.insert(pUser); + } + + if (pUser->hContact == 0) { + MCONTACT hContact = db_add_contact(); + Proto_AddToContact(hContact, m_szModuleName); + + Clist_SetGroup(hContact, m_wszDefaultGroup); + setId(hContact, DB_KEY_ID, id); + setWString(hContact, DB_KEY_NICK, username); + setDword(hContact, DB_KEY_DISCR, iDiscriminator); + + pUser->hContact = hContact; + } + + CheckAvatarChange(pUser->hContact, user["avatar"].as_mstring()); + return pUser; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +CMStringW PrepareMessageText(const JSONNode &pRoot) +{ + CMStringW wszText = pRoot["content"].as_mstring(); + + bool bDelimiterAdded = false; + for (auto &it : pRoot["attachments"]) { + CMStringW wszUrl = it["url"].as_mstring(); + if (!wszUrl.IsEmpty()) { + if (!bDelimiterAdded) { + bDelimiterAdded = true; + wszText.Append(L"\n-----------------"); + } + wszText.AppendFormat(L"\n%s: %s", TranslateT("Attachment"), wszUrl.c_str()); + } + } + + for (auto &it : pRoot["embeds"]) { + wszText.Append(L"\n-----------------"); + + CMStringW str = it["url"].as_mstring(); + wszText.AppendFormat(L"\n%s: %s", TranslateT("Embed"), str.c_str()); + + str = it["provider"]["name"].as_mstring() + L" " + it["type"].as_mstring(); + if (str.GetLength() > 1) + wszText.AppendFormat(L"\n\t%s", str.c_str()); + + str = it["description"].as_mstring(); + if (!str.IsEmpty()) + wszText.AppendFormat(L"\n\t%s", str.c_str()); + + str = it["thumbnail"]["url"].as_mstring(); + if (!str.IsEmpty()) + wszText.AppendFormat(L"\n%s: %s", TranslateT("Preview"), str.c_str()); + } + + return wszText; +} + +///////////////////////////////////////////////////////////////////////////////////////// + +void CDiscordProto::ProcessType(CDiscordUser *pUser, const JSONNode &pRoot) +{ + switch (pRoot["type"].as_int()) { + case 1: // confirmed + Contact::PutOnList(pUser->hContact); + delSetting(pUser->hContact, DB_KEY_REQAUTH); + delSetting(pUser->hContact, "ApparentMode"); + break; + + case 3: // expecting authorization + Contact::RemoveFromList(pUser->hContact); + if (!getByte(pUser->hContact, DB_KEY_REQAUTH, 0)) { + setByte(pUser->hContact, DB_KEY_REQAUTH, 1); + + CMStringA szId(FORMAT, "%lld", pUser->id); + DB::AUTH_BLOB blob(pUser->hContact, T2Utf(pUser->wszUsername), nullptr, nullptr, szId, nullptr); + + PROTORECVEVENT pre = { 0 }; + pre.timestamp = (uint32_t)time(0); + pre.lParam = blob.size(); + pre.szMessage = blob; + ProtoChainRecv(pUser->hContact, PSR_AUTH, 0, (LPARAM)&pre); + } + break; + } +} + +///////////////////////////////////////////////////////////////////////////////////////// + +void CDiscordProto::ParseSpecialChars(SESSION_INFO *si, CMStringW &str) +{ + for (int i = 0; (i = str.Find('<', i)) != -1; i++) { + int iEnd = str.Find('>', i + 1); + if (iEnd == -1) + return; + + CMStringW wszWord = str.Mid(i + 1, iEnd - i - 1); + if (wszWord[0] == '@') { // member highlight + int iStart = 1; + if (wszWord[1] == '!') + iStart++; + + USERINFO *ui = g_chatApi.UM_FindUser(si, wszWord.c_str() + iStart); + if (ui != nullptr) + str.Replace(L"<" + wszWord + L">", CMStringW(ui->pszNick) + L": "); + } + else if (wszWord[0] == '#') { + CDiscordUser *pUser = FindUserByChannel(_wtoi64(wszWord.c_str() + 1)); + if (pUser != nullptr) { + ptrW wszNick(getWStringA(pUser->hContact, DB_KEY_NICK)); + if (wszNick != nullptr) + str.Replace(L"<" + wszWord + L">", wszNick); + } + } + } +} diff --git a/protocols/Discord/src/version.h b/protocols/Discord/src/version.h new file mode 100644 index 0000000000..138a7eaaec --- /dev/null +++ b/protocols/Discord/src/version.h @@ -0,0 +1,13 @@ +#define __MAJOR_VERSION 0 +#define __MINOR_VERSION 6 +#define __RELEASE_NUM 2 +#define __BUILD_NUM 11 + +#include <stdver.h> + +#define __PLUGIN_NAME "Discord protocol" +#define __FILENAME "Discord.dll" +#define __DESCRIPTION "Discord support for Miranda NG." +#define __AUTHOR "George Hazan" +#define __AUTHORWEB "https://miranda-ng.org/p/Discord/" +#define __COPYRIGHT "© 2016-22 Miranda NG team" diff --git a/protocols/Discord/src/voice.cpp b/protocols/Discord/src/voice.cpp new file mode 100644 index 0000000000..6e41bde300 --- /dev/null +++ b/protocols/Discord/src/voice.cpp @@ -0,0 +1,116 @@ +/* +Copyright © 2016-22 Miranda NG team + +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, see <http://www.gnu.org/licenses/>. +*/ + +#include "stdafx.h" + +///////////////////////////////////////////////////////////////////////////////////////// +// call operations (voice & video) + +void CDiscordProto::OnCommandCallCreated(const JSONNode &pRoot) +{ + for (auto &it : pRoot["voice_states"]) { + SnowFlake channelId = ::getId(pRoot["channel_id"]); + auto *pUser = FindUserByChannel(channelId); + if (pUser == nullptr) { + debugLogA("Call from unknown channel %lld, skipping", channelId); + continue; + } + + auto *pCall = new CDiscordVoiceCall(); + pCall->szId = it["session_id"].as_mstring(); + pCall->channelId = channelId; + pCall->startTime = time(0); + arVoiceCalls.insert(pCall); + + char *szMessage = TranslateU("Incoming call"); + DBEVENTINFO dbei = {}; + dbei.szModule = m_szModuleName; + dbei.timestamp = pCall->startTime; + dbei.eventType = EVENT_INCOMING_CALL; + dbei.cbBlob = uint32_t(mir_strlen(szMessage) + 1); + dbei.pBlob = (uint8_t *)szMessage; + dbei.flags = DBEF_UTF; + db_event_add(pUser->hContact, &dbei); + } +} + +void CDiscordProto::OnCommandCallDeleted(const JSONNode &pRoot) +{ + SnowFlake channelId = ::getId(pRoot["channel_id"]); + auto *pUser = FindUserByChannel(channelId); + if (pUser == nullptr) { + debugLogA("Call from unknown channel %lld, skipping", channelId); + return; + } + + int elapsed = 0, currTime = time(0); + for (auto &call : arVoiceCalls.rev_iter()) + if (call->channelId == channelId) { + elapsed = currTime - call->startTime; + arVoiceCalls.removeItem(&call); + break; + } + + if (!elapsed) { + debugLogA("Call from channel %lld isn't registered, skipping", channelId); + return; + } + + CMStringA szMessage(FORMAT, TranslateU("Call ended, %d seconds long"), elapsed); + DBEVENTINFO dbei = {}; + dbei.szModule = m_szModuleName; + dbei.timestamp = currTime; + dbei.eventType = EVENT_CALL_FINISHED; + dbei.cbBlob = uint32_t(szMessage.GetLength() + 1); + dbei.pBlob = (uint8_t *)szMessage.c_str(); + dbei.flags = DBEF_UTF; + db_event_add(pUser->hContact, &dbei); +} + +void CDiscordProto::OnCommandCallUpdated(const JSONNode&) +{ +} + +///////////////////////////////////////////////////////////////////////////////////////// +// Events & services + +INT_PTR __cdecl CDiscordProto::VoiceCaps(WPARAM, LPARAM) +{ + return VOICE_CAPS_VOICE | VOICE_CAPS_CALL_CONTACT; +} + +int __cdecl CDiscordProto::OnVoiceState(WPARAM wParam, LPARAM) +{ + auto *pVoice = (VOICE_CALL *)wParam; + if (mir_strcmp(pVoice->moduleName, m_szModuleName)) + return 0; + + CDiscordVoiceCall *pCall = nullptr; + for (auto &it : arVoiceCalls) + if (it->szId == pVoice->id) { + pCall = it; + break; + } + + if (pCall == nullptr) { + debugLogA("Unknown call: %s, exiting", pVoice->id); + return 0; + } + + debugLogA("Call %s state changed to %d", pVoice->id, pVoice->state); + return 0; +} |