summaryrefslogtreecommitdiff
path: root/protocols/Teams/src
diff options
context:
space:
mode:
Diffstat (limited to 'protocols/Teams/src')
-rw-r--r--protocols/Teams/src/main.cpp80
-rw-r--r--protocols/Teams/src/resource.h44
-rw-r--r--protocols/Teams/src/stdafx.cxx18
-rw-r--r--protocols/Teams/src/stdafx.h91
-rw-r--r--protocols/Teams/src/teams_avatars.cpp218
-rw-r--r--protocols/Teams/src/teams_chatrooms.cpp665
-rw-r--r--protocols/Teams/src/teams_contacts.cpp321
-rw-r--r--protocols/Teams/src/teams_files.cpp314
-rw-r--r--protocols/Teams/src/teams_history.cpp195
-rw-r--r--protocols/Teams/src/teams_http.cpp198
-rw-r--r--protocols/Teams/src/teams_login.cpp254
-rw-r--r--protocols/Teams/src/teams_menus.cpp92
-rw-r--r--protocols/Teams/src/teams_menus.h30
-rw-r--r--protocols/Teams/src/teams_messages.cpp337
-rw-r--r--protocols/Teams/src/teams_options.cpp99
-rw-r--r--protocols/Teams/src/teams_polling.cpp137
-rw-r--r--protocols/Teams/src/teams_popups.cpp100
-rw-r--r--protocols/Teams/src/teams_profile.cpp153
-rw-r--r--protocols/Teams/src/teams_proto.cpp297
-rw-r--r--protocols/Teams/src/teams_proto.h386
-rw-r--r--protocols/Teams/src/teams_search.cpp62
-rw-r--r--protocols/Teams/src/teams_server.cpp104
-rw-r--r--protocols/Teams/src/teams_trouter.cpp342
-rw-r--r--protocols/Teams/src/teams_utils.cpp774
-rw-r--r--protocols/Teams/src/teams_utils.h75
-rw-r--r--protocols/Teams/src/version.h13
26 files changed, 5399 insertions, 0 deletions
diff --git a/protocols/Teams/src/main.cpp b/protocols/Teams/src/main.cpp
new file mode 100644
index 0000000000..07778b2405
--- /dev/null
+++ b/protocols/Teams/src/main.cpp
@@ -0,0 +1,80 @@
+/*
+Copyright (C) 2025 Miranda NG team (https://miranda-ng.org)
+
+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 version 2
+of the License.
+
+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;
+
+char g_szMirVer[100];
+HANDLE g_hCallEvent;
+HANDLE hExtraXStatus;
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+PLUGININFOEX pluginInfoEx =
+{
+ sizeof(PLUGININFOEX),
+ __PLUGIN_NAME,
+ PLUGIN_MAKE_VERSION(__MAJOR_VERSION, __MINOR_VERSION, __RELEASE_NUM, __BUILD_NUM),
+ __DESCRIPTION,
+ __AUTHOR,
+ __COPYRIGHT,
+ __AUTHORWEB,
+ UNICODE_AWARE,
+ // {DCD56CEC-C61B-4275-A010-8C65C5B48815}
+ { 0xDCD56CEC, 0xC61B, 0x4275, { 0xa0, 0x10, 0x8c, 0x65, 0xc5, 0x84, 0x88, 0x15 }}
+};
+
+CMPlugin::CMPlugin() :
+ ACCPROTOPLUGIN<CTeamsProto>("Teams", pluginInfoEx)
+{
+ SetUniqueId("id");
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+extern "C" __declspec(dllexport) const MUUID MirandaInterfaces[] = { MIID_PROTOCOL, MIID_LAST };
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+static IconItem iconList[] = {
+ { LPGEN("Protocol icon"), "main", IDI_TEAMS },
+ { LPGEN("Create new chat icon"), "conference", IDI_CONFERENCE },
+ { LPGEN("Block user icon"), "user_block", IDI_BLOCKUSER },
+ { LPGEN("Unblock user icon"), "user_unblock", IDI_UNBLOCKUSER },
+ { LPGEN("Incoming call icon"), "inc_call", IDI_CALL },
+ { LPGEN("Notification icon"), "notify", IDI_NOTIFY },
+ { LPGEN("Error icon"), "error", IDI_ERRORICON },
+ { LPGEN("Action icon"), "me_action", IDI_ACTION_ME }
+
+};
+
+int CMPlugin::Load()
+{
+ registerIcon("Protocols/" MODULENAME, iconList, MODULENAME);
+
+ g_hCallEvent = CreateHookableEvent(MODULENAME "/IncomingCall");
+ return 0;
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+int CMPlugin::Unload()
+{
+ DestroyHookableEvent(g_hCallEvent);
+ return 0;
+}
diff --git a/protocols/Teams/src/resource.h b/protocols/Teams/src/resource.h
new file mode 100644
index 0000000000..ea9044c6da
--- /dev/null
+++ b/protocols/Teams/src/resource.h
@@ -0,0 +1,44 @@
+//{{NO_DEPENDENCIES}}
+// Microsoft Visual C++ generated include file.
+// Used by W:\miranda-ng\protocols\Teams\res\Resource.rc
+//
+#define IDI_TEAMS 100
+#define IDC_LOGIN 101
+#define IDD_ACCOUNT_MANAGER 103
+#define IDD_OPTIONS_MAIN 104
+#define IDD_MOOD 105
+#define IDC_GROUP 106
+#define IDD_DEVICECODE 107
+#define IDD_GC_CREATE 111
+#define IDD_GC_INVITE 112
+#define IDI_CONFERENCE 114
+#define IDI_BLOCKUSER 118
+#define IDI_UNBLOCKUSER 119
+#define IDI_CALL 120
+#define IDI_NOTIFY 121
+#define IDI_ERRORICON 122
+#define IDI_ACTION_ME 123
+#define IDC_TEXT 1001
+#define IDC_AUTOSYNC 1028
+#define IDC_LOCALTIME 1029
+#define IDC_CLIST 1030
+#define IDC_TITLE 1031
+#define IDC_CONTACT 1032
+#define IDC_USEHOST 1035
+#define IDC_BBCODES 1036
+#define IDC_MOOD_COMBO 1037
+#define IDC_CHANGEPASS 1038
+#define IDC_MOOD_EMOJI 1039
+#define IDC_MOOD_TEXT 1041
+#define IDC_LOGOUT 1042
+
+// Next default values for new objects
+//
+#ifdef APSTUDIO_INVOKED
+#ifndef APSTUDIO_READONLY_SYMBOLS
+#define _APS_NEXT_RESOURCE_VALUE 126
+#define _APS_NEXT_COMMAND_VALUE 40001
+#define _APS_NEXT_CONTROL_VALUE 1044
+#define _APS_NEXT_SYMED_VALUE 101
+#endif
+#endif
diff --git a/protocols/Teams/src/stdafx.cxx b/protocols/Teams/src/stdafx.cxx
new file mode 100644
index 0000000000..b64bcca703
--- /dev/null
+++ b/protocols/Teams/src/stdafx.cxx
@@ -0,0 +1,18 @@
+/*
+Copyright (C) 2025 Miranda NG team (https://miranda-ng.org)
+
+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 version 2
+of the License.
+
+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/Teams/src/stdafx.h b/protocols/Teams/src/stdafx.h
new file mode 100644
index 0000000000..93478998e9
--- /dev/null
+++ b/protocols/Teams/src/stdafx.h
@@ -0,0 +1,91 @@
+#ifndef _COMMON_H_
+#define _COMMON_H_
+
+#include <windows.h>
+
+#include <malloc.h>
+#include <time.h>
+#include <string>
+#include <vector>
+#include <regex>
+#include <map>
+#include <memory>
+#include <functional>
+
+#include <newpluginapi.h>
+
+#include <m_protoint.h>
+#include <m_protosvc.h>
+
+#include <m_database.h>
+#include <m_langpack.h>
+#include <m_clistint.h>
+#include <m_options.h>
+#include <m_netlib.h>
+#include <m_popup.h>
+#include <m_icolib.h>
+#include <m_userinfo.h>
+#include <m_timezones.h>
+#include <m_contacts.h>
+#include <m_message.h>
+#include <m_avatars.h>
+#include <m_skin.h>
+#include <m_chat_int.h>
+#include <m_genmenu.h>
+#include <m_clc.h>
+#include <m_json.h>
+#include <m_gui.h>
+#include <m_imgsrvc.h>
+#include <m_xml.h>
+#include <m_assocmgr.h>
+#include <m_file.h>
+
+extern char g_szMirVer[];
+extern HANDLE g_hCallEvent;
+
+struct MessageId
+{
+ ULONGLONG id;
+ HANDLE handle;
+};
+
+#include "resource.h"
+#include "version.h"
+#include "teams_menus.h"
+#include "teams_utils.h"
+
+#define MODULENAME "Teams"
+
+class CTeamsProto;
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+#define SKYPEWEB_CLIENTINFO_NAME "swx-skype.com"
+#define SKYPEWEB_CLIENTINFO_VERSION "908/1.85.0.29"
+
+enum SkypeHost
+{
+ HOST_API,
+ HOST_CONTACTS,
+ HOST_LOGIN,
+ HOST_TEAMS,
+ HOST_TEAMS_API,
+ HOST_CHATS,
+ HOST_GROUPS,
+ HOST_PRESENCE,
+ HOST_OTHER
+};
+
+struct AsyncHttpRequest : public MTHttpRequest<CTeamsProto>
+{
+ SkypeHost m_host;
+ MCONTACT hContact = 0;
+
+ AsyncHttpRequest(int type, SkypeHost host, LPCSTR url = nullptr, MTHttpRequestHandler pFunc = nullptr);
+
+ void AddAuthentication(CTeamsProto *ppro);
+};
+
+#include "teams_proto.h"
+
+#endif //_COMMON_H_
diff --git a/protocols/Teams/src/teams_avatars.cpp b/protocols/Teams/src/teams_avatars.cpp
new file mode 100644
index 0000000000..a535cab005
--- /dev/null
+++ b/protocols/Teams/src/teams_avatars.cpp
@@ -0,0 +1,218 @@
+/*
+Copyright (c) 2025 Miranda NG team (https://miranda-ng.org)
+
+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 version 2
+of the License.
+
+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 CTeamsProto::GetAvatarFileName(MCONTACT hContact, wchar_t *pszDest, size_t cbLen)
+{
+ CMStringW wszPath(GetAvatarPath());
+ wszPath += '\\';
+
+ const wchar_t *szFileType = ProtoGetAvatarExtension(getByte(hContact, "AvatarType", PA_FORMAT_JPEG));
+ CMStringA username(getId(hContact));
+ username.Replace("live:", "__live_");
+ username.Replace("facebook:", "__facebook_");
+ wszPath.AppendFormat(L"%S%s", username.c_str(), szFileType);
+
+ wcsncpy_s(pszDest, cbLen, wszPath, _TRUNCATE);
+}
+
+void CTeamsProto::ReloadAvatarInfo(MCONTACT hContact)
+{
+ if (hContact == NULL) {
+ ReportSelfAvatarChanged();
+ return;
+ }
+
+ PROTO_AVATAR_INFORMATION ai = { 0 };
+ ai.hContact = hContact;
+ SvcGetAvatarInfo(0, (LPARAM)&ai);
+}
+
+void CTeamsProto::SetAvatarUrl(MCONTACT hContact, const CMStringW &tszUrl)
+{
+ ptrW oldUrl(getWStringA(hContact, "AvatarUrl"));
+ if (oldUrl != NULL)
+ if (tszUrl == oldUrl)
+ return;
+
+ if (tszUrl.IsEmpty()) {
+ delSetting(hContact, "AvatarUrl");
+ ProtoBroadcastAck(hContact, ACKTYPE_AVATAR, ACKRESULT_SUCCESS, nullptr);
+ }
+ else {
+ setWString(hContact, "AvatarUrl", tszUrl);
+ setByte(hContact, "NeedNewAvatar", 1);
+
+ PROTO_AVATAR_INFORMATION ai = {};
+ ai.hContact = hContact;
+ GetAvatarFileName(ai.hContact, ai.filename, _countof(ai.filename));
+ ai.format = ProtoGetAvatarFormat(ai.filename);
+ ProtoBroadcastAck(hContact, ACKTYPE_AVATAR, ACKRESULT_SUCCESS, &ai);
+ }
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+// Avatar services for Miranda
+
+INT_PTR CTeamsProto::SvcGetAvatarCaps(WPARAM wParam, LPARAM lParam)
+{
+ switch (wParam) {
+ case AF_MAXSIZE:
+ ((POINT*)lParam)->x = 98;
+ ((POINT*)lParam)->y = 98;
+ break;
+
+ case AF_MAXFILESIZE:
+ return 32000;
+
+ case AF_PROPORTION:
+ return PIP_SQUARE;
+
+ case AF_FORMATSUPPORTED:
+ case AF_ENABLED:
+ case AF_DONTNEEDDELAYS:
+ case AF_FETCHIFPROTONOTVISIBLE:
+ case AF_FETCHIFCONTACTOFFLINE:
+ return 1;
+ }
+ return 0;
+}
+
+INT_PTR CTeamsProto::SvcGetAvatarInfo(WPARAM, LPARAM lParam)
+{
+ PROTO_AVATAR_INFORMATION *pai = (PROTO_AVATAR_INFORMATION *)lParam;
+
+ pai->format = getByte(pai->hContact, "AvatarType", PA_FORMAT_JPEG);
+
+ wchar_t tszFileName[MAX_PATH];
+ GetAvatarFileName(pai->hContact, tszFileName, _countof(tszFileName));
+ wcsncpy(pai->filename, tszFileName, _countof(pai->filename));
+
+ if (::_waccess(pai->filename, 0) == 0 && !getBool(pai->hContact, "NeedNewAvatar", 0))
+ return GAIR_SUCCESS;
+
+ if (IsOnline())
+ if (ReceiveAvatar(pai->hContact))
+ return GAIR_WAITFOR;
+
+ debugLogA("No avatar");
+ return GAIR_NOAVATAR;
+}
+
+INT_PTR CTeamsProto::SvcGetMyAvatar(WPARAM wParam, LPARAM lParam)
+{
+ wchar_t path[MAX_PATH];
+ GetAvatarFileName(NULL, path, _countof(path));
+ wcsncpy((wchar_t *)wParam, path, (int)lParam);
+ return 0;
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+// Avatars' receiving
+
+void CTeamsProto::OnReceiveAvatar(MHttpResponse *response, AsyncHttpRequest *pRequest)
+{
+ if (response == nullptr || response->body.IsEmpty())
+ return;
+
+ MCONTACT hContact = pRequest->hContact;
+ if (response->resultCode != 200)
+ return;
+
+ PROTO_AVATAR_INFORMATION ai = { 0 };
+ ai.format = ProtoGetBufferFormat(response->body);
+ setByte(hContact, "AvatarType", ai.format);
+ GetAvatarFileName(hContact, ai.filename, _countof(ai.filename));
+
+ FILE *out = _wfopen(ai.filename, L"wb");
+ if (out == nullptr) {
+ ProtoBroadcastAck(hContact, ACKTYPE_AVATAR, ACKRESULT_FAILED, &ai, 0);
+ return;
+ }
+
+ fwrite(response->body, 1, response->body.GetLength(), out);
+ fclose(out);
+ setByte(hContact, "NeedNewAvatar", 0);
+ ProtoBroadcastAck(hContact, ACKTYPE_AVATAR, ACKRESULT_SUCCESS, &ai, 0);
+}
+
+bool CTeamsProto::ReceiveAvatar(MCONTACT hContact)
+{
+ ptrA szUrl(getStringA(hContact, "AvatarUrl"));
+ if (!mir_strlen(szUrl))
+ return false;
+
+ auto *pReq = new AsyncHttpRequest(REQUEST_GET, HOST_OTHER, szUrl, &CTeamsProto::OnReceiveAvatar);
+ pReq->hContact = hContact;
+ pReq->flags |= NLHRF_REDIRECT;
+ PushRequest(pReq);
+
+ debugLogA("Requested to read an avatar from '%s'", szUrl.get());
+ return true;
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+// Setting my own avatar
+
+void CTeamsProto::OnSentAvatar(MHttpResponse *response, AsyncHttpRequest*)
+{
+ TeamsReply root(response);
+ if (root.error())
+ return;
+}
+
+INT_PTR CTeamsProto::SvcSetMyAvatar(WPARAM, LPARAM lParam)
+{
+ wchar_t *path = (wchar_t*)lParam;
+ wchar_t avatarPath[MAX_PATH];
+ GetAvatarFileName(NULL, avatarPath, _countof(avatarPath));
+ if (path != nullptr) {
+ if (CopyFile(path, avatarPath, FALSE)) {
+ FILE *hFile = _wfopen(path, L"rb");
+ if (hFile) {
+ fseek(hFile, 0, SEEK_END);
+ size_t length = ftell(hFile);
+ if (length != -1) {
+ rewind(hFile);
+
+ mir_ptr<uint8_t> data((uint8_t*)mir_alloc(length));
+
+ if (data != NULL && fread(data, sizeof(uint8_t), length, hFile) == length) {
+ const char *szMime = FreeImage_GetFIFMimeType(FreeImage_GetFIFFromFilenameU(path));
+
+ auto *pReq = new AsyncHttpRequest(REQUEST_PUT, HOST_API, 0, &CTeamsProto::OnSentAvatar);
+ pReq->m_szUrl.AppendFormat("/users/%s/profile/avatar", m_szSkypename.MakeLower().c_str());
+ pReq->AddHeader("Content-Type", szMime);
+ pReq->m_szParam.Truncate((int)length);
+ memcpy(pReq->m_szParam.GetBuffer(), data, (int)length);
+ PushRequest(pReq);
+
+ fclose(hFile);
+ return 0;
+ }
+ }
+ fclose(hFile);
+ }
+ }
+ return -1;
+ }
+ else if (IsFileExists(avatarPath))
+ DeleteFile(avatarPath);
+
+ return 0;
+}
diff --git a/protocols/Teams/src/teams_chatrooms.cpp b/protocols/Teams/src/teams_chatrooms.cpp
new file mode 100644
index 0000000000..ca0621bed5
--- /dev/null
+++ b/protocols/Teams/src/teams_chatrooms.cpp
@@ -0,0 +1,665 @@
+/*
+Copyright (c) 2025 Miranda NG team (https://miranda-ng.org)
+
+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 version 2
+of the License.
+
+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 CTeamsProto::InitGroupChatModule()
+{
+ GCREGISTER gcr = {};
+ gcr.dwFlags = GC_DATABASE | GC_PERSISTENT;
+ gcr.iMaxText = 0;
+ gcr.ptszDispName = m_tszUserName;
+ gcr.pszModule = m_szModuleName;
+ Chat_Register(&gcr);
+
+ HookProtoEvent(ME_GC_EVENT, &CTeamsProto::OnGroupChatEventHook);
+ HookProtoEvent(ME_GC_BUILDMENU, &CTeamsProto::OnGroupChatMenuHook);
+
+ CreateProtoService(PS_JOINCHAT, &CTeamsProto::OnJoinChatRoom);
+ CreateProtoService(PS_LEAVECHAT, &CTeamsProto::OnLeaveChatRoom);
+}
+
+SESSION_INFO* CTeamsProto::StartChatRoom(const wchar_t *tid, const wchar_t *tname, const char *pszVersion)
+{
+ // Create the group chat session
+ SESSION_INFO *si = Chat_NewSession(GCW_CHATROOM, m_szModuleName, tid, tname);
+ if (!si)
+ return nullptr;
+
+ bool bFetchInfo = si->arUsers.getCount() == 0;
+ if (pszVersion) {
+ CMStringA oldVersion(getMStringA(si->hContact, "Version"));
+ if (oldVersion != pszVersion)
+ bFetchInfo = true;
+ }
+
+ if (bFetchInfo) {
+ // Create user statuses
+ Chat_AddGroup(si, TranslateT("Admin"));
+ Chat_AddGroup(si, TranslateT("User"));
+
+ GetChatInfo(tid);
+ }
+
+ // Finish initialization
+ Chat_Control(si, (getBool("HideChats", 1) ? WINDOW_HIDDEN : SESSION_INITDONE));
+ Chat_Control(si, SESSION_ONLINE);
+ return si;
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+// Group chat invitation dialog
+
+class CSkypeInviteDlg : public CTeamsDlgBase
+{
+ CCtrlCombo m_combo;
+
+public:
+ MCONTACT m_hContact = 0;
+
+ CSkypeInviteDlg(CTeamsProto *proto) :
+ CTeamsDlgBase(proto, IDD_GC_INVITE),
+ m_combo(this, IDC_CONTACT)
+ {}
+
+ bool OnInitDialog() override
+ {
+ for (auto &hContact : m_proto->AccContacts())
+ if (!m_proto->isChatRoom(hContact))
+ m_combo.AddString(Clist_GetContactDisplayName(hContact), hContact);
+ return true;
+ }
+
+ bool OnApply() override
+ {
+ m_hContact = m_combo.GetCurData();
+ return true;
+ }
+};
+
+int CTeamsProto::OnGroupChatEventHook(WPARAM, LPARAM lParam)
+{
+ GCHOOK *gch = (GCHOOK*)lParam;
+ if (!gch)
+ return 0;
+
+ auto *si = gch->si;
+ if (mir_strcmp(si->pszModule, m_szModuleName) != 0)
+ return 0;
+
+ T2Utf chat_id(si->ptszID), user_id(gch->ptszUID);
+
+ switch (gch->iType) {
+ case GC_USER_MESSAGE:
+ SendChatMessage(si, gch->ptszText);
+ break;
+
+ case GC_USER_PRIVMESS:
+ {
+ MCONTACT hContact = FindContact(user_id);
+ if (hContact == NULL) {
+ hContact = AddContact(user_id, T2Utf(gch->ptszNick), true);
+ setWord(hContact, "Status", ID_STATUS_ONLINE);
+ Contact::Hide(hContact);
+ }
+ CallService(MS_MSG_SENDMESSAGEW, hContact, 0);
+ }
+ break;
+
+ case GC_USER_LOGMENU:
+ switch (gch->dwData) {
+ case 10:
+ {
+ CSkypeInviteDlg dlg(this);
+ if (dlg.DoModal())
+ if (dlg.m_hContact != NULL)
+ InviteUserToChat(chat_id, getId(dlg.m_hContact), "User");
+ }
+ break;
+
+ case 20:
+ OnLeaveChatRoom(si->hContact, NULL);
+ break;
+
+ case 30:
+ CMStringW newTopic = ChangeTopicForm();
+ if (!newTopic.IsEmpty())
+ SetChatProperty(chat_id, "topic", T2Utf(newTopic.GetBuffer()));
+ break;
+ }
+ break;
+
+ case GC_USER_NICKLISTMENU:
+ switch (gch->dwData) {
+ case 10:
+ KickChatUser(chat_id, user_id);
+ break;
+ case 30:
+ InviteUserToChat(chat_id, user_id, "Admin");
+ break;
+ case 40:
+ InviteUserToChat(chat_id, user_id, "User");
+ break;
+ case 50:
+ ptrW tnick_old(GetChatContactNick(si, gch->ptszUID, gch->ptszText));
+
+ ENTER_STRING pForm = {};
+ pForm.type = ESF_COMBO;
+ pForm.caption = TranslateT("Enter new nickname");
+ pForm.szModuleName = m_szModuleName;
+ pForm.szDataPrefix = "renamenick_";
+
+ if (EnterString(&pForm)) {
+ if (si->hContact == NULL)
+ break; // This probably shouldn't happen, but if chat is NULL for some reason, do nothing
+
+ ptrW tnick_new(pForm.ptszResult);
+ bool reset = mir_wstrlen(tnick_new) == 0;
+ if (reset) {
+ // User fill blank name, which means we reset the custom nick
+ db_unset(si->hContact, "UsersNicks", user_id);
+ tnick_new = GetChatContactNick(si, gch->ptszUID, gch->ptszText);
+ }
+
+ if (!mir_wstrcmp(tnick_old, tnick_new))
+ break; // New nick is same, do nothing
+
+ GCEVENT gce = { si, GC_EVENT_NICK };
+ gce.dwFlags = GCEF_ADDTOLOG;
+ gce.pszNick.w = tnick_old;
+ gce.bIsMe = IsMe(user_id);
+ gce.pszUID.w = gch->ptszUID;
+ gce.pszText.w= tnick_new;
+ gce.time = time(0);
+ Chat_Event(&gce);
+
+ if (!reset)
+ db_set_ws(si->hContact, "UsersNicks", user_id, tnick_new);
+ }
+ break;
+ }
+ break;
+ }
+ return 1;
+}
+
+INT_PTR CTeamsProto::OnJoinChatRoom(WPARAM hContact, LPARAM)
+{
+ if (hContact) {
+ ptrW idT(getWStringA(hContact, DBKEY_ID));
+ ptrW nameT(getWStringA(hContact, "Nick"));
+ StartChatRoom(idT, nameT != NULL ? nameT : idT);
+ }
+ return 0;
+}
+
+INT_PTR CTeamsProto::OnLeaveChatRoom(WPARAM hContact, LPARAM)
+{
+ if (!IsOnline())
+ return 1;
+
+ if (hContact && IDYES == MessageBox(nullptr, TranslateT("This chat is going to be destroyed forever with all its contents. This action cannot be undone. Are you sure?"), TranslateT("Warning"), MB_YESNO | MB_ICONQUESTION)) {
+ ptrW idT(getWStringA(hContact, DBKEY_ID));
+ auto *si = Chat_Find(idT, m_szModuleName);
+ Chat_Control(si, SESSION_OFFLINE);
+ Chat_Terminate(si);
+
+ db_delete_contact(hContact, CDF_DEL_CONTACT);
+ }
+ return 0;
+}
+
+/* CHAT EVENT */
+
+bool CTeamsProto::OnChatEvent(const JSONNode &node)
+{
+ CMStringW wszChatId(UrlToSkypeId(node["conversationLink"].as_mstring()));
+ CMStringW szFromId(UrlToSkypeId(node["from"].as_mstring()));
+
+ CMStringW wszTopic(node["threadtopic"].as_mstring());
+ CMStringW wszContent(node["content"].as_mstring());
+
+ SESSION_INFO *si = Chat_Find(wszChatId, m_szModuleName);
+ if (si == nullptr) {
+ si = StartChatRoom(wszChatId, wszTopic);
+ if (si == nullptr) {
+ debugLogW(L"unable to create chat %s", wszChatId.c_str());
+ return true;
+ }
+ }
+
+ std::string messageType = node["messagetype"].as_string();
+ if (messageType == "ThreadActivity/AddMember") {
+ // <addmember><eventtime>1429186229164</eventtime><initiator>8:initiator</initiator><target>8:user</target></addmember>
+ TiXmlDocument doc;
+ if (!doc.Parse(T2Utf(wszContent))) {
+ if (auto *pRoot = doc.FirstChildElement("addmember")) {
+ auto *pszTarget = XmlGetChildText(pRoot, "target");
+ if (!AddChatContact(si, Utf2T(pszTarget), L"User")) {
+ OBJLIST<char> arIds(1);
+ arIds.insert(newStr(pszTarget));
+ GetChatMembers(arIds, si);
+ }
+ }
+ }
+ return true;
+ }
+
+ if (messageType == "ThreadActivity/DeleteMember") {
+ // <deletemember><eventtime>1429186229164</eventtime><initiator>8:initiator</initiator><target>8:user</target></deletemember>
+ TiXmlDocument doc;
+ if (!doc.Parse(T2Utf(wszContent))) {
+ if (auto *pRoot = doc.FirstChildElement("deletemember")) {
+ CMStringW target = Utf2T(XmlGetChildText(pRoot, "target"));
+ CMStringW initiator = Utf2T(XmlGetChildText(pRoot, "initiator"));
+ RemoveChatContact(si, target, initiator);
+ }
+ }
+ return true;
+ }
+
+ if (messageType == "ThreadActivity/TopicUpdate") {
+ // <topicupdate><eventtime>1429532702130</eventtime><initiator>8:user</initiator><value>test topic</value></topicupdate>
+ TiXmlDocument doc;
+ if (!doc.Parse(T2Utf(wszContent))) {
+ if (auto *pRoot = doc.FirstChildElement("topicupdate")) {
+ CMStringW initiator = Utf2T(XmlGetChildText(pRoot, "initiator"));
+ CMStringW value = Utf2T(XmlGetChildText(pRoot, "value"));
+ Chat_ChangeSessionName(si, value);
+
+ GCEVENT gce = { si, GC_EVENT_TOPIC };
+ gce.pszUID.w = initiator;
+ gce.pszNick.w = GetSkypeNick(initiator);
+ gce.pszText.w = wszTopic;
+ Chat_Event(&gce);
+ }
+ }
+ return true;
+ }
+
+ if (messageType == "ThreadActivity/RoleUpdate") {
+ // <roleupdate><eventtime>1429551258363</eventtime><initiator>8:user</initiator><target><id>8:user1</id><role>admin</role></target></roleupdate>
+ TiXmlDocument doc;
+ if (!doc.Parse(T2Utf(wszContent))) {
+ if (auto *pRoot = doc.FirstChildElement("roleupdate")) {
+ CMStringW initiator = Utf2T(UrlToSkypeId(XmlGetChildText(pRoot, "initiator")));
+
+ auto *pTarget = pRoot->FirstChildElement("target");
+ if (pTarget) {
+ CMStringW id = Utf2T(UrlToSkypeId(XmlGetChildText(pTarget, "id")));
+ const char *role = XmlGetChildText(pTarget, "role");
+
+ GCEVENT gce = { si, !mir_strcmpi(role, "Admin") ? GC_EVENT_ADDSTATUS : GC_EVENT_REMOVESTATUS };
+ gce.dwFlags = GCEF_ADDTOLOG;
+ gce.pszNick.w = id;
+ gce.pszUID.w = id;
+ gce.pszText.w = initiator;
+ gce.time = time(0);
+ gce.bIsMe = IsMe(T2Utf(id));
+ gce.pszStatus.w = TranslateT("Admin");
+ Chat_Event(&gce);
+ }
+ }
+ }
+ return true;
+ }
+
+ // some slack, let's drop it
+ if (messageType == "ThreadActivity/HistoryDisclosedUpdate" || messageType == "ThreadActivity/JoiningEnabledUpdate")
+ return true;
+
+ return false;
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+void CTeamsProto::SendChatMessage(SESSION_INFO *si, const wchar_t *tszMessage)
+{
+ if (!IsOnline())
+ return;
+
+ CMStringA szMessage(ptrA(mir_utf8encodeW(tszMessage)));
+ szMessage.TrimRight();
+ bool bRich = AddBbcodes(szMessage);
+
+ JSONNode node;
+ node << INT64_PARAM("clientmessageid", getRandomId()) << CHAR_PARAM("messagetype", bRich ? "RichText" : "Text")
+ << CHAR_PARAM("contenttype", "text") << CHAR_PARAM("content", szMessage);
+ if (strncmp(szMessage, "/me ", 4) == 0)
+ node << INT_PARAM("skypeemoteoffset", 4);
+
+ CMStringA szUrl = "/users/ME/conversations/" + mir_urlEncode(T2Utf(si->ptszID)) + "/messages";
+ AsyncHttpRequest *pReq = new AsyncHttpRequest(REQUEST_POST, HOST_CHATS, szUrl, &CTeamsProto::OnMessageSent);
+ pReq->m_szParam = node.write().c_str();
+ pReq->pUserInfo = new COwnMessage(szMessage);
+ pReq->hContact = si->hContact;
+ PushRequest(pReq);
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+void CTeamsProto::GetChatMembers(const LIST<char> &ids, SESSION_INFO *si)
+{
+ auto *pReq = new AsyncHttpRequest(REQUEST_POST, HOST_CHATS, "/profiles", &CTeamsProto::OnGetChatMembers);
+
+ JSONNode node, mris(JSON_ARRAY); mris.set_name("mris");
+ for (auto &it : ids)
+ mris.push_back(JSONNode("", it));
+ node << mris << CHAR_PARAM("locale", "en-US");
+ pReq->m_szParam = node.write().c_str();
+
+ pReq->pUserInfo = si;
+ PushRequest(pReq);
+};
+
+void CTeamsProto::OnGetChatMembers(MHttpResponse *response, AsyncHttpRequest *pRequest)
+{
+ TeamsReply reply(response);
+ if (reply.error())
+ return;
+
+ auto &root = reply.data();
+ auto *si = (SESSION_INFO *)pRequest->pUserInfo;
+
+ for (auto &it : root["profiles"]) {
+ CMStringW wszUserId(Utf2T(it.name()));
+ if (auto *pUser = g_chatApi.UM_FindUser(si, wszUserId)) {
+ auto &pProfile = it["profile"];
+ if (auto &pName = pProfile["displayName"])
+ replaceStrW(pUser->pszNick, pName.as_mstring());
+ }
+ }
+
+ if (g_chatApi.OnChangeNick)
+ g_chatApi.OnChangeNick(si);
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+void CTeamsProto::OnGetChatInfo(MHttpResponse *response, AsyncHttpRequest*)
+{
+ TeamsReply reply(response);
+ if (reply.error())
+ return;
+
+ auto &root = reply.data();
+ const JSONNode &properties = root["properties"];
+ if (!properties["capabilities"] || properties["capabilities"].empty())
+ return;
+
+ CMStringW wszChatId(UrlToSkypeId(root["messages"].as_mstring()));
+ auto *si = Chat_Find(wszChatId, m_szModuleName);
+ if (si == nullptr)
+ return;
+
+ setString(si->hContact, "Version", root["version"].as_string().c_str());
+
+ OBJLIST<char> arIds(1);
+ for (auto &member : root["members"]) {
+ CMStringW username(UrlToSkypeId(member["userLink"].as_mstring()));
+ CMStringW role = member["role"].as_mstring();
+ if (!AddChatContact(si, username, role, true))
+ arIds.insert(newStr(mir_u2a(username)));
+ }
+
+ if (arIds.getCount())
+ GetChatMembers(arIds, si);
+
+ GetServerHistory(si->hContact, 100, 0, true);
+}
+
+void CTeamsProto::GetChatInfo(const wchar_t *chatId)
+{
+ auto *pReq = new AsyncHttpRequest(REQUEST_GET, HOST_CHATS, 0, &CTeamsProto::OnGetChatInfo);
+ pReq->m_szUrl.AppendFormat("/threads/%S", chatId);
+ pReq << CHAR_PARAM("view", "msnp24Equivalent");
+ PushRequest(pReq);
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+wchar_t* CTeamsProto::GetChatContactNick(SESSION_INFO *si, const wchar_t *id, const wchar_t *name, bool *isQualified)
+{
+ if (isQualified)
+ *isQualified = true;
+
+ // Check if there's a user with this id in a chat
+ if (auto *pUser = g_chatApi.UM_FindUser(si, id))
+ return mir_wstrdup(pUser->pszNick);
+
+ // Check if there is custom nick for this chat contact
+ if (auto *tname = db_get_wsa(si->hContact, "UsersNicks", T2Utf(id)))
+ return tname;
+
+ // Check if we have this contact in database
+ if (IsMe(id)) {
+ // Return my nick
+ if (auto *tname = getWStringA("Nick"))
+ return tname;
+ }
+ else {
+ MCONTACT hContact = FindContact(id);
+ if (hContact != NULL) {
+ // Primarily return custom name
+ if (auto *tname = db_get_wsa(hContact, "CList", "MyHandle"))
+ return tname;
+
+ // If not exists, then user nick
+ if (auto *tname = getWStringA(hContact, "Nick"))
+ return tname;
+ }
+ }
+
+ if (isQualified)
+ *isQualified = false;
+
+ // Return default value as nick - given name or user id
+ if (name != nullptr)
+ return mir_wstrdup(name);
+ return mir_wstrdup(GetSkypeNick(id));
+}
+
+void CTeamsProto::InviteUserToChat(const char *chatId, const char *skypename, const char *role)
+{
+ JSONNode node;
+ node << CHAR_PARAM("role", role);
+
+ auto *pReq = new AsyncHttpRequest(REQUEST_PUT, HOST_CHATS);
+ pReq->m_szUrl.AppendFormat("/threads/%s/members/%s", chatId, skypename);
+ pReq->m_szParam = node.write().c_str();
+ PushRequest(pReq);
+}
+
+void CTeamsProto::SetChatProperty(const char *chatId, const char *propname, const char *value)
+{
+ JSONNode node;
+ node << CHAR_PARAM(propname, value);
+
+ auto *pReq = new AsyncHttpRequest(REQUEST_PUT, HOST_CHATS);
+ pReq->m_szUrl.AppendFormat("/threads/%s/properties?name=%s", chatId, propname);
+ pReq->m_szParam = node.write().c_str();
+ PushRequest(pReq);
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+bool CTeamsProto::AddChatContact(SESSION_INFO *si, const wchar_t *id, const wchar_t *role, bool isChange)
+{
+ bool isQualified;
+ ptrW szNick(GetChatContactNick(si, id, 0, &isQualified));
+
+ GCEVENT gce = { si, GC_EVENT_JOIN };
+ gce.dwFlags = GCEF_ADDTOLOG;
+ gce.pszNick.w = szNick;
+ gce.pszUID.w = id;
+ gce.time = !isChange ? time(0) : NULL;
+ gce.bIsMe = IsMe(id);
+ gce.pszStatus.w = TranslateW(role);
+ Chat_Event(&gce);
+
+ return isQualified;
+}
+
+void CTeamsProto::RemoveChatContact(SESSION_INFO *si, const wchar_t *id, const wchar_t *initiator)
+{
+ if (IsMe(id))
+ return;
+
+ ptrW szNick(GetChatContactNick(si, id));
+ ptrW szInitiator(GetChatContactNick(si, initiator));
+
+ GCEVENT gce = { si, GC_EVENT_KICK };
+ gce.dwFlags = GCEF_ADDTOLOG;
+ gce.pszNick.w = szNick;
+ gce.pszUID.w = id;
+ gce.time = time(0);
+ gce.bIsMe = IsMe(id);
+ gce.pszStatus.w = szInitiator;
+ Chat_Event(&gce);
+}
+
+void CTeamsProto::KickChatUser(const char *chatId, const char *userId)
+{
+ PushRequest(new AsyncHttpRequest(REQUEST_DELETE, HOST_CHATS, "/threads/" + mir_urlEncode(chatId) + "/members/" + mir_urlEncode(userId)));
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+// Group chat creation dialog
+
+class CSkypeGCCreateDlg : public CTeamsDlgBase
+{
+ CCtrlClc m_clc;
+
+public:
+ OBJLIST<char> m_ContactsList;
+
+ CSkypeGCCreateDlg(CTeamsProto *proto) :
+ CTeamsDlgBase(proto, IDD_GC_CREATE),
+ m_clc(this, IDC_CLIST),
+ m_ContactsList(1)
+ {
+ m_clc.OnListRebuilt = Callback(this, &CSkypeGCCreateDlg::FilterList);
+ }
+
+ ~CSkypeGCCreateDlg()
+ {
+ }
+
+ bool OnInitDialog() override
+ {
+ SetWindowLongPtr(m_clc.GetHwnd(), GWL_STYLE,
+ GetWindowLongPtr(m_clc.GetHwnd(), GWL_STYLE) | CLS_CHECKBOXES | CLS_HIDEEMPTYGROUPS | CLS_USEGROUPS | CLS_GREYALTERNATE);
+ m_clc.SendMsg(CLM_SETEXSTYLE, CLS_EX_DISABLEDRAGDROP | CLS_EX_TRACKSELECT, 0);
+
+ ResetListOptions(&m_clc);
+ return true;
+ }
+
+ bool OnApply() override
+ {
+ for (auto &hContact : m_proto->AccContacts())
+ if (!m_proto->isChatRoom(hContact))
+ if (HANDLE hItem = m_clc.FindContact(hContact))
+ if (m_clc.GetCheck(hItem))
+ m_ContactsList.insert(newStr(m_proto->getId(hContact)));
+
+ m_ContactsList.insert(newStr(m_proto->m_szSkypename));
+ return true;
+ }
+
+ void FilterList(CCtrlClc *)
+ {
+ for (auto &hContact : Contacts()) {
+ char *proto = Proto_GetBaseAccountName(hContact);
+ if (mir_strcmp(proto, m_proto->m_szModuleName) || m_proto->isChatRoom(hContact))
+ if (HANDLE hItem = m_clc.FindContact(hContact))
+ m_clc.DeleteItem(hItem);
+ }
+ }
+
+ void ResetListOptions(CCtrlClc *)
+ {
+ m_clc.SetHideEmptyGroups(true);
+ m_clc.SetHideOfflineRoot(true);
+ }
+};
+
+INT_PTR CTeamsProto::SvcCreateChat(WPARAM, LPARAM)
+{
+ if (IsOnline()) {
+ CSkypeGCCreateDlg dlg(this);
+ if (dlg.DoModal()) {
+ JSONNode node;
+ JSONNode members(JSON_ARRAY); members.set_name("members");
+
+ for (auto &it : dlg.m_ContactsList) {
+ JSONNode member;
+ member << CHAR_PARAM("id", it) << CHAR_PARAM("role", !mir_strcmpi(it, m_szSkypename) ? "Admin" : "User");
+ members << member;
+ }
+ node << members;
+
+ auto *pReq = new AsyncHttpRequest(REQUEST_POST, HOST_CHATS, "/threads");
+ pReq->m_szParam = node.write().c_str();
+ PushRequest(pReq);
+ return 0;
+ }
+ }
+ return 1;
+}
+
+/* Menus */
+
+int CTeamsProto::OnGroupChatMenuHook(WPARAM, LPARAM lParam)
+{
+ GCMENUITEMS *gcmi = (GCMENUITEMS*)lParam;
+ if (mir_strcmpi(gcmi->pszModule, m_szModuleName)) return 0;
+
+ if (gcmi->Type == MENU_ON_LOG) {
+ static const struct gc_item Items[] =
+ {
+ { LPGENW("&Invite user..."), 10, MENU_ITEM, FALSE },
+ { LPGENW("&Leave chat session"), 20, MENU_ITEM, FALSE },
+ { LPGENW("&Change topic..."), 30, MENU_ITEM, FALSE }
+ };
+ Chat_AddMenuItems(gcmi->hMenu, _countof(Items), Items, &g_plugin);
+ }
+ else if (gcmi->Type == MENU_ON_NICKLIST) {
+ static const struct gc_item Items[] =
+ {
+ { LPGENW("Kick &user"), 10, MENU_ITEM },
+ { nullptr, 0, MENU_SEPARATOR },
+ { LPGENW("Set &role"), 20, MENU_NEWPOPUP },
+ { LPGENW("&Admin"), 30, MENU_POPUPITEM },
+ { LPGENW("&User"), 40, MENU_POPUPITEM },
+ { LPGENW("Change nick..."), 50, MENU_ITEM },
+ };
+ Chat_AddMenuItems(gcmi->hMenu, _countof(Items), Items, &g_plugin);
+ }
+
+ return 0;
+}
+
+CMStringW CTeamsProto::ChangeTopicForm()
+{
+ CMStringW caption(FORMAT, L"[%s] %s", _A2T(m_szModuleName).get(), TranslateT("Enter new chatroom topic"));
+ ENTER_STRING pForm = {};
+ pForm.type = ESF_MULTILINE;
+ pForm.caption = caption;
+ pForm.szModuleName = m_szModuleName;
+ return (!EnterString(&pForm)) ? CMStringW() : CMStringW(ptrW(pForm.ptszResult));
+}
diff --git a/protocols/Teams/src/teams_contacts.cpp b/protocols/Teams/src/teams_contacts.cpp
new file mode 100644
index 0000000000..19c2817d6f
--- /dev/null
+++ b/protocols/Teams/src/teams_contacts.cpp
@@ -0,0 +1,321 @@
+/*
+Copyright (c) 2025 Miranda NG team (https://miranda-ng.org)
+
+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 version 2
+of the License.
+
+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 CTeamsProto::SetContactStatus(MCONTACT hContact, uint16_t status)
+{
+ uint16_t oldStatus = getWord(hContact, "Status", ID_STATUS_OFFLINE);
+ if (oldStatus != status) {
+ setWord(hContact, "Status", status);
+ if (status == ID_STATUS_OFFLINE)
+ delSetting(hContact, "MirVer");
+ }
+}
+
+void CTeamsProto::SetChatStatus(MCONTACT hContact, int iStatus)
+{
+ ptrW tszChatID(getWStringA(hContact, DBKEY_ID));
+ if (tszChatID != NULL)
+ Chat_Control(Chat_Find(tszChatID, m_szModuleName), (iStatus == ID_STATUS_OFFLINE) ? SESSION_OFFLINE : SESSION_ONLINE);
+}
+
+MCONTACT CTeamsProto::GetContactFromAuthEvent(MEVENT hEvent)
+{
+ uint32_t body[3];
+ DBEVENTINFO dbei = {};
+ dbei.cbBlob = sizeof(uint32_t) * 2;
+ dbei.pBlob = (char *)&body;
+
+ if (db_event_get(hEvent, &dbei))
+ return INVALID_CONTACT_ID;
+
+ if (dbei.eventType != EVENTTYPE_AUTHREQUEST)
+ return INVALID_CONTACT_ID;
+
+ if (mir_strcmp(dbei.szModule, m_szModuleName) != 0)
+ return INVALID_CONTACT_ID;
+ return DbGetAuthEventContact(&dbei);
+}
+
+MCONTACT CTeamsProto::FindContact(const char *skypeId)
+{
+ for (auto &hContact : AccContacts())
+ if (!mir_strcmpi(skypeId, ptrA(getUStringA(hContact, DBKEY_ID))))
+ return hContact;
+
+ return 0;
+}
+
+MCONTACT CTeamsProto::FindContact(const wchar_t *skypeId)
+{
+ for (auto &hContact : AccContacts())
+ if (!mir_wstrcmpi(skypeId, getMStringW(hContact, DBKEY_ID)))
+ return hContact;
+
+ return 0;
+}
+
+MCONTACT CTeamsProto::AddContact(const char *skypeId, const char *nick, bool isTemporary)
+{
+ MCONTACT hContact = FindContact(skypeId);
+ if (hContact)
+ return hContact;
+
+ hContact = db_add_contact();
+ Proto_AddToContact(hContact, m_szModuleName);
+
+ setString(hContact, DBKEY_ID, skypeId);
+ setUString(hContact, "Nick", (nick) ? nick : GetSkypeNick(skypeId));
+
+ if (m_wstrCListGroup) {
+ Clist_GroupCreate(0, m_wstrCListGroup);
+ Clist_SetGroup(hContact, m_wstrCListGroup);
+ }
+
+ setByte(hContact, "Auth", 1);
+ setByte(hContact, "Grant", 1);
+
+ if (isTemporary)
+ Contact::RemoveFromList(hContact);
+ return hContact;
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+void CTeamsProto::LoadContactsAuth(MHttpResponse *response, AsyncHttpRequest*)
+{
+ TeamsReply reply(response);
+ if (reply.error())
+ return;
+
+ auto &root = reply.data();
+ for (auto &item : root["invite_list"]) {
+ std::string skypeId = item["mri"].as_string();
+ std::string reason = item["greeting"].as_string();
+
+ time_t eventTime = 0;
+ for (auto &it : item["invites"])
+ eventTime = Utils_IsoToUnixTime(it["time"].as_string());
+
+ std::string displayName = item["displayname"].as_string();
+ const char *szNick = (displayName.empty()) ? nullptr : displayName.c_str();
+
+ MCONTACT hContact = AddContact(skypeId.c_str(), szNick);
+ time_t lastEventTime = getDword(hContact, "LastAuthRequestTime");
+ if (lastEventTime && lastEventTime >= eventTime)
+ continue;
+
+ setUString(hContact, "Nick", displayName.c_str());
+
+ setDword(hContact, "LastAuthRequestTime", eventTime);
+ delSetting(hContact, "Auth");
+
+ DB::AUTH_BLOB blob(hContact, displayName.c_str(), nullptr, nullptr, skypeId.c_str(), reason.c_str());
+
+ DB::EventInfo dbei;
+ dbei.iTimestamp = time(0);
+ dbei.cbBlob = blob.size();
+ dbei.pBlob = blob;
+ ProtoChainRecv(hContact, PSR_AUTH, 0, (LPARAM)&dbei);
+ }
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+void CTeamsProto::RefreshContactsInfo()
+{
+ PushRequest(new AsyncHttpRequest(REQUEST_GET, HOST_CONTACTS, "/users/SELF/contacts", &CTeamsProto::OnGotContactsInfo));
+}
+
+void CTeamsProto::OnGotContactsInfo(MHttpResponse *response, AsyncHttpRequest*)
+{
+ TeamsReply reply(response);
+ if (reply.error())
+ return;
+
+ auto &root = reply.data();
+ for (auto &item : root["contacts"]) {
+ CMStringA szSkypeId = item["person_id"].as_mstring();
+ if (!IsPossibleUserType(szSkypeId))
+ continue;
+
+ MCONTACT hContact = AddContact(szSkypeId, nullptr);
+ if (szSkypeId == "28:e7a9407c-2467-4a04-9546-70081f4ea80d")
+ m_hMyContact = hContact;
+
+ std::string displayName = item["display_name"].as_string();
+ if (!displayName.empty()) {
+ if (m_hMyContact == hContact) {
+ displayName = getMStringU("Nick");
+ displayName += " ";
+ displayName += TranslateU("(You)");
+
+ setWord(hContact, "Status", ID_STATUS_ONLINE);
+ }
+ setUString(hContact, "Nick", displayName.c_str());
+ }
+
+ if (item["authorized"].as_bool()) {
+ delSetting(hContact, "Auth");
+ delSetting(hContact, "Grant");
+ }
+ else setByte(hContact, "Grant", 1);
+
+ if (item["blocked"].as_bool())
+ setByte(hContact, "IsBlocked", 1);
+ else
+ delSetting(hContact, "IsBlocked");
+
+ ptrW wszGroup(Clist_GetGroup(hContact));
+ if (wszGroup == nullptr && m_wstrCListGroup) {
+ Clist_GroupCreate(0, m_wstrCListGroup);
+ Clist_SetGroup(hContact, m_wstrCListGroup);
+ }
+
+ auto &profile = item["profile"];
+ SetString(hContact, "Homepage", profile["website"]);
+
+ auto wstr = profile["birthday"].as_mstring();
+ if (!wstr.IsEmpty() ) {
+ int nYear, nMonth, nDay;
+ if (swscanf(wstr, L"%d-%d-%d", &nYear, &nMonth, &nDay) == 3)
+ Contact::SetBirthday(hContact, nDay, nMonth, nYear);
+ }
+
+ wstr = profile["gender"].as_mstring();
+ if (wstr == "male")
+ setByte(hContact, "Gender", 'M');
+ else if (wstr == "female")
+ setByte(hContact, "Gender", 'F');
+
+ auto &name = profile["name"];
+ SetString(hContact, "FirstName", name["first"]);
+ SetString(hContact, "LastName", name["surname"]);
+
+ if (auto &pMood = profile["mood"])
+ db_set_ws(hContact, "CList", "StatusMsg", RemoveHtml(pMood.as_mstring()));
+
+ SetAvatarUrl(hContact, profile["avatar_url"].as_mstring());
+ ReloadAvatarInfo(hContact);
+
+ for (auto &phone : profile["phones"]) {
+ CMStringW number = phone["number"].as_mstring();
+
+ auto wszType = phone["type"].as_mstring();
+ if (wszType == L"mobile")
+ setWString(hContact, "Cellular", number);
+ else if (wszType == L"phone")
+ setWString(hContact, "Phone", number);
+ }
+ }
+
+ PushRequest(new AsyncHttpRequest(REQUEST_GET, HOST_CONTACTS, "/users/SELF/invites", &CTeamsProto::LoadContactsAuth));
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+void CTeamsProto::GetShortInfo(const OBJLIST<char> &ids)
+{
+ auto *pReq = new AsyncHttpRequest(REQUEST_POST, HOST_TEAMS_API, "/users/fetchShortProfile?isMailAddress=false&canBeSmtpAddress=false&enableGuest=true&includeIBBarredUsers=true&skypeTeamsInfo=true&includeBots=true");
+
+ for (auto &it : ids) {
+ if (pReq->m_szParam.IsEmpty())
+ pReq->m_szParam = "[";
+ else
+ pReq->m_szParam += ",";
+ pReq->m_szParam.AppendFormat("\"%s\"", it);
+ }
+ pReq->m_szParam += "]";
+
+ PushRequest(pReq);
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+INT_PTR CTeamsProto::OnRequestAuth(WPARAM hContact, LPARAM)
+{
+ return AuthRequest(hContact, 0);
+}
+
+INT_PTR CTeamsProto::OnGrantAuth(WPARAM hContact, LPARAM)
+{
+ if (hContact == INVALID_CONTACT_ID)
+ return 1;
+
+ PushRequest(new AsyncHttpRequest(REQUEST_POST, HOST_CONTACTS, "/users/SELF/invites/" + mir_urlEncode(getId(hContact)) + "/accept"));
+ return 0;
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+bool CTeamsProto::OnContactDeleted(MCONTACT hContact, uint32_t flags)
+{
+ if (IsOnline() && hContact && (flags & CDF_DEL_CONTACT)) {
+ CMStringA szId(getId(hContact));
+ AsyncHttpRequest *pReq = (isChatRoom(hContact))
+ ? new AsyncHttpRequest(REQUEST_DELETE, HOST_GROUPS, "/threads/" + mir_urlEncode(szId))
+ : new AsyncHttpRequest(REQUEST_DELETE, HOST_CONTACTS, "/users/SELF/contacts/" + mir_urlEncode(szId));
+ PushRequest(pReq);
+ }
+ return true;
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+void CTeamsProto::OnBlockContact(MHttpResponse *response, AsyncHttpRequest *pRequest)
+{
+ MCONTACT hContact = (DWORD_PTR)pRequest->pUserInfo;
+ if (response != nullptr)
+ Contact::Hide(hContact);
+}
+
+INT_PTR CTeamsProto::BlockContact(WPARAM hContact, LPARAM)
+{
+ if (!IsOnline()) return 1;
+
+ if (IDYES == MessageBox(NULL, TranslateT("Are you sure?"), TranslateT("Warning"), MB_YESNO | MB_ICONQUESTION)) {
+ auto *pReq = new AsyncHttpRequest(REQUEST_PUT, HOST_CONTACTS, "/users/SELF/contacts/blocklist/" + mir_urlEncode(getId(hContact)), &CTeamsProto::OnBlockContact);
+ pReq->m_szParam = "{\"report_abuse\":\"false\",\"ui_version\":\"skype.com\"}";
+ pReq->pUserInfo = (void *)hContact;
+ PushRequest(pReq);
+ }
+ return 0;
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+void CTeamsProto::OnUnblockContact(MHttpResponse *response, AsyncHttpRequest *pRequest)
+{
+ if (response == nullptr)
+ return;
+
+ MCONTACT hContact = (DWORD_PTR)pRequest->pUserInfo;
+ Contact::Hide(hContact, false);
+ delSetting(hContact, "IsBlocked");
+}
+
+INT_PTR CTeamsProto::UnblockContact(WPARAM hContact, LPARAM)
+{
+ if (!IsOnline()) return 1;
+
+ auto *pReq = new AsyncHttpRequest(REQUEST_DELETE, HOST_CONTACTS, "/users/SELF/contacts/blocklist/" + mir_urlEncode(getId(hContact)), &CTeamsProto::OnUnblockContact);
+ pReq->pUserInfo = (void *)hContact;
+ pReq << CHAR_PARAM("reporterIp", "123.123.123.123") << CHAR_PARAM("uiVersion", g_szMirVer); // TODO: user ip address
+ PushRequest(pReq);
+ return 0;
+}
diff --git a/protocols/Teams/src/teams_files.cpp b/protocols/Teams/src/teams_files.cpp
new file mode 100644
index 0000000000..bab5d72c6d
--- /dev/null
+++ b/protocols/Teams/src/teams_files.cpp
@@ -0,0 +1,314 @@
+/*
+Copyright (c) 2025 Miranda NG team (https://miranda-ng.org)
+
+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 version 2
+of the License.
+
+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"
+
+////////////////////////////////////////////////////////////////////////////////////////
+// File receiving
+
+static void __cdecl DownloadCallack(size_t iProgress, void *pParam)
+{
+ auto *ofd = (OFDTHREAD *)pParam;
+
+ DBVARIANT dbv = { DBVT_DWORD };
+ dbv.dVal = unsigned(iProgress);
+ db_event_setJson(ofd->hDbEvent, "ft", &dbv);
+}
+
+void CTeamsProto::ReceiveFileThread(void *param)
+{
+ auto *ofd = (OFDTHREAD *)param;
+
+ DB::EventInfo dbei(ofd->hDbEvent);
+ if (IsOnline() && dbei && !strcmp(dbei.szModule, m_szModuleName) && dbei.eventType == EVENTTYPE_FILE) {
+ DB::FILE_BLOB blob(dbei);
+
+ if (ofd->bCopy) {
+ ofd->wszPath = Utf2T(blob.getUrl()).get();
+ ofd->pCallback->Invoke(*ofd);
+ }
+ else {
+ CMStringA szCookie, szUrl;
+ szCookie.AppendFormat("skypetoken_asm=%s", m_szSkypeToken.c_str());
+
+ auto &json = dbei.getJson();
+ auto skft = json["skft"].as_string();
+ {
+ const char *preview;
+ if (skft == "Picture.1")
+ preview = "imgpsh_mobile_save_anim";
+ else if (skft == "Video.1")
+ preview = "video";
+ else
+ preview = "original";
+
+ MHttpRequest nlhr(REQUEST_GET);
+ nlhr.flags = NLHRF_HTTP11 | NLHRF_NOUSERAGENT;
+ nlhr.m_szUrl = blob.getUrl();
+ nlhr.m_szUrl.AppendFormat("/views/%s/status", preview);
+ nlhr.AddHeader("Accept", "*/*");
+ nlhr.AddHeader("Accept-Encoding", "gzip, deflate");
+ nlhr.AddHeader("Cookie", szCookie);
+ NLHR_PTR response(Netlib_HttpTransaction(m_hNetlibUser, &nlhr));
+ if (response) {
+ TeamsReply reply(response);
+ if (!reply.error()) {
+ auto &root = reply.data();
+ if (root["content_state"].as_string() == "ready")
+ szUrl = root["view_location"].as_string().c_str();
+ }
+ }
+ }
+
+ if (!szUrl.IsEmpty()) {
+ MHttpRequest nlhr(REQUEST_GET);
+ nlhr.flags = NLHRF_HTTP11 | NLHRF_NOUSERAGENT;
+ nlhr.m_szUrl = blob.getUrl();
+ if (skft == "Picture.1")
+ nlhr.m_szUrl += "/views/imgpsh_fullsize_anim";
+ else if (skft == "Video.1")
+ nlhr.m_szUrl += "/views/video";
+ else
+ nlhr.m_szUrl += "/views/original";
+
+ nlhr.AddHeader("Accept", "*/*");
+ nlhr.AddHeader("Accept-Encoding", "gzip, deflate");
+ nlhr.AddHeader("Cookie", szCookie);
+
+ NLHR_PTR reply(Netlib_DownloadFile(m_hNetlibUser, &nlhr, ofd->wszPath, DownloadCallack, ofd));
+ if (reply && reply->resultCode == 200) {
+ struct _stat st;
+ _wstat(ofd->wszPath, &st);
+
+ DBVARIANT dbv = { DBVT_DWORD };
+ dbv.dVal = st.st_size;
+ db_event_setJson(ofd->hDbEvent, "ft", &dbv);
+
+ ofd->Finish();
+ }
+ }
+ }
+ }
+
+ delete ofd;
+}
+
+INT_PTR CTeamsProto::SvcOfflineFile(WPARAM param, LPARAM)
+{
+ ForkThread(&CTeamsProto::ReceiveFileThread, (void *)param);
+ return 0;
+}
+
+////////////////////////////////////////////////////////////////////////////////////////
+// File sending
+
+#define FILETRANSFER_FAILED(fup) { ProtoBroadcastAck(fup->hContact, ACKTYPE_FILE, ACKRESULT_FAILED, (HANDLE)fup); delete fup; fup = nullptr;}
+
+void CTeamsProto::SendFile(CFileUploadParam *fup)
+{
+ auto *pwszFileName = &fup->arFileName[0];
+ if (!IsOnline() || _waccess(pwszFileName, 0)) {
+ FILETRANSFER_FAILED(fup);
+ return;
+ }
+
+ if (auto *pBitmap = FreeImage_LoadU(FreeImage_GetFIFFromFilenameU(pwszFileName), pwszFileName)) {
+ fup->isPicture = true;
+ fup->width = FreeImage_GetWidth(pBitmap);
+ fup->height = FreeImage_GetHeight(pBitmap);
+ FreeImage_Unload(pBitmap);
+ }
+ else fup->isPicture = false;
+
+ ProtoBroadcastAck(fup->hContact, ACKTYPE_FILE, ACKRESULT_CONNECTING, (HANDLE)fup);
+
+ // create upload slot
+ auto *pReq = new AsyncHttpRequest(REQUEST_POST, HOST_OTHER, "https://api.asm.skype.com/v1/objects", &CTeamsProto::OnASMObjectCreated);
+ pReq->flags &= (~NLHRF_DUMPASTEXT);
+ pReq->pUserInfo = fup;
+
+ pReq->AddHeader("Authorization", CMStringA(FORMAT, "skype_token %s", m_szSkypeToken.c_str()));
+ pReq->AddHeader("Content-Type", "application/json");
+ pReq->AddHeader("X-Client-Version", "0/0.0.0.0");
+
+ CMStringA szContact(getId(fup->hContact));
+ T2Utf uszFileName(&fup->arFileName[0]);
+ const char *szFileName = strrchr(uszFileName.get() + 1, '\\');
+
+ JSONNode node;
+ if (fup->isPicture)
+ node << CHAR_PARAM("type", "pish/image");
+ else
+ node << CHAR_PARAM("type", "sharing/file");
+
+ JSONNode jPermission(JSON_ARRAY); jPermission.set_name(szContact.c_str()); jPermission << CHAR_PARAM("", "read");
+ JSONNode jPermissions; jPermissions.set_name("permissions"); jPermissions << jPermission;
+ node << CHAR_PARAM("filename", szFileName) << jPermissions;
+ pReq->m_szParam = node.write().c_str();
+ PushRequest(pReq);
+}
+
+void CTeamsProto::OnASMObjectCreated(MHttpResponse *response, AsyncHttpRequest *pRequest)
+{
+ auto *fup = (CFileUploadParam*)pRequest->pUserInfo;
+ if (response == nullptr || response->body.IsEmpty()) {
+LBL_Error:
+ FILETRANSFER_FAILED(fup);
+ return;
+ }
+
+ if (response->resultCode != 200 && response->resultCode != 201) {
+ debugLogA("Object creation failed with error code %d", response->resultCode);
+ goto LBL_Error;
+ }
+
+ JSONNode node = JSONNode::parse(response->body);
+ std::string strObjectId = node["id"].as_string();
+ if (strObjectId.empty()) {
+ debugLogA("Invalid server response (empty object id)");
+ goto LBL_Error;
+ }
+
+ fup->uid = mir_strdup(strObjectId.c_str());
+ FILE *pFile = _wfopen(&fup->arFileName[0], L"rb");
+ if (pFile == nullptr)
+ goto LBL_Error;
+
+ fseek(pFile, 0, SEEK_END);
+ long lFileLen = ftell(pFile);
+ if (lFileLen < 1) {
+ fclose(pFile);
+ goto LBL_Error;
+ }
+
+ fseek(pFile, 0, SEEK_SET);
+
+ mir_ptr<uint8_t> pData((uint8_t*)mir_alloc(lFileLen));
+ long lBytes = (long)fread(pData, sizeof(uint8_t), lFileLen, pFile);
+ fclose(pFile);
+
+ if (lBytes != lFileLen)
+ goto LBL_Error;
+
+ fup->size = lBytes;
+ ProtoBroadcastAck(fup->hContact, ACKTYPE_FILE, ACKRESULT_INITIALISING, (HANDLE)fup);
+
+ // upload file to the previously created slot
+ auto *pReq = new AsyncHttpRequest(REQUEST_PUT, HOST_OTHER, 0, &CTeamsProto::OnASMObjectUploaded);
+ pReq->m_szUrl.Format("https://api.asm.skype.com/v1/objects/%s/content/%s",
+ strObjectId.c_str(), fup->isPicture ? "imgpsh" : "original");
+ pReq->pUserInfo = fup;
+
+ pReq->AddHeader("Authorization", CMStringA(FORMAT, "skype_token %s", m_szSkypeToken.c_str()));
+ pReq->AddHeader("Content-Type", fup->isPicture ? "application" : "application/octet-stream");
+
+ pReq->m_szParam.Truncate(lBytes);
+ memcpy(pReq->m_szParam.GetBuffer(), pData, lBytes);
+ PushRequest(pReq);
+}
+
+void CTeamsProto::OnASMObjectUploaded(MHttpResponse *response, AsyncHttpRequest *pRequest)
+{
+ auto *fup = (CFileUploadParam *)pRequest->pUserInfo;
+ if (response == nullptr) {
+ FILETRANSFER_FAILED(fup);
+ return;
+ }
+
+ wchar_t *tszFile = wcsrchr(&fup->arFileName[0], L'\\') + 1;
+
+ TiXmlDocument doc;
+ auto *pRoot = doc.NewElement("URIObject");
+ doc.InsertEndChild(pRoot);
+
+ pRoot->SetAttribute("doc_id", fup->uid.get());
+ pRoot->SetAttribute("uri", CMStringA(FORMAT, "https://api.asm.skype.com/v1/objects/%s", fup->uid.get()));
+
+ // is that a picture?
+ CMStringA href;
+ if (fup->isPicture) {
+ pRoot->SetAttribute("type", "Picture.1");
+ pRoot->SetAttribute("url_thumbnail", CMStringA(FORMAT, "https://api.asm.skype.com/v1/objects/%s/views/imgt1_anim", fup->uid.get()));
+ pRoot->SetAttribute("width", fup->width);
+ pRoot->SetAttribute("height", fup->height);
+ pRoot->SetText("To view this shared photo, go to:");
+
+ href.Format("https://login.skype.com/login/sso?go=xmmfallback?pic=%s", fup->uid.get());
+ }
+ else {
+ pRoot->SetAttribute("type", "File.1");
+ pRoot->SetAttribute("url_thumbnail", CMStringA(FORMAT, "https://api.asm.skype.com/v1/objects/%s/views/original", fup->uid.get()));
+ pRoot->SetText("To view this file, go to:");
+
+ href.Format("https://login.skype.com/login/sso?go=webclient.xmm&docid=%s", fup->uid.get());
+ }
+
+ auto *xmlA = doc.NewElement("a"); xmlA->SetText(href);
+ xmlA->SetAttribute("href", href);
+ pRoot->InsertEndChild(xmlA);
+
+ auto *xmlOrigName = doc.NewElement("OriginalName"); xmlOrigName->SetAttribute("v", tszFile); pRoot->InsertEndChild(xmlOrigName);
+ auto *xmlSize = doc.NewElement("FileSize"); xmlSize->SetAttribute("v", (int)fup->size); pRoot->InsertEndChild(xmlSize);
+
+ if (fup->isPicture) {
+ auto xmlMeta = doc.NewElement("meta");
+ xmlMeta->SetAttribute("type", "photo"); xmlMeta->SetAttribute("originalName", tszFile);
+ pRoot->InsertEndChild(xmlMeta);
+ }
+
+ tinyxml2::XMLPrinter printer(0, true);
+ doc.Print(&printer);
+
+ // create a new file transfer event using previously filled slot
+ auto *pReq = new AsyncHttpRequest(REQUEST_POST, HOST_CHATS);
+ pReq->m_szUrl.AppendFormat("/users/ME/conversations/%s/messages", mir_urlEncode(getId(fup->hContact)).c_str());
+ pReq->hContact = fup->hContact;
+
+ JSONNode ref(JSON_ARRAY); ref.set_name("amsreferences"); ref << CHAR_PARAM("", fup->uid);
+
+ JSONNode node;
+ if (fup->isPicture)
+ node << CHAR_PARAM("messagetype", "RichText/UriObject");
+ else
+ node << CHAR_PARAM("messagetype", "RichText/Media_GenericFile");
+
+ node << INT64_PARAM("clientmessageid", getRandomId()) << CHAR_PARAM("contenttype", "text") << CHAR_PARAM("content", printer.CStr()) << ref;
+ pReq->m_szParam = node.write().c_str();
+
+ PushRequest(pReq);
+
+ // if that's last file in the queue, finish file transfer, or proceed with the next file
+ if (fup->arFileName.getCount() == 1) {
+ ProtoBroadcastAck(fup->hContact, ACKTYPE_FILE, ACKRESULT_SUCCESS, (HANDLE)fup);
+ delete fup;
+ }
+ else {
+ fup->arFileName.remove(int(0));
+ ProtoBroadcastAck(fup->hContact, ACKTYPE_FILE, ACKRESULT_NEXTFILE, (HANDLE)fup);
+ SendFile(fup);
+ }
+}
+
+HANDLE CTeamsProto::SendFile(MCONTACT hContact, const wchar_t *szDescription, wchar_t **ppszFiles)
+{
+ if (!IsOnline())
+ return INVALID_HANDLE_VALUE;
+
+ CFileUploadParam *fup = new CFileUploadParam(hContact, ppszFiles, szDescription);
+ SendFile(fup);
+ return fup;
+}
diff --git a/protocols/Teams/src/teams_history.cpp b/protocols/Teams/src/teams_history.cpp
new file mode 100644
index 0000000000..4630b33aa3
--- /dev/null
+++ b/protocols/Teams/src/teams_history.cpp
@@ -0,0 +1,195 @@
+/*
+Copyright (c) 2025 Miranda NG team (https://miranda-ng.org)
+
+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 version 2
+of the License.
+
+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"
+
+/* HISTORY SYNC */
+
+void CTeamsProto::FetchMissingHistory(const JSONNode &node, MCONTACT hContact)
+{
+ const JSONNode &lastMessage = node["lastMessage"];
+ if (lastMessage && hContact) {
+ int64_t id = _atoi64(lastMessage["id"].as_string().c_str());
+ auto lastMsgTime = getLastTime(hContact);
+ if (lastMsgTime && lastMsgTime < id && m_bAutoHistorySync)
+ GetServerHistory(hContact, 100, lastMsgTime, true);
+ }
+}
+
+void CTeamsProto::OnSyncConversations(MHttpResponse *response, AsyncHttpRequest *)
+{
+ TeamsReply reply(response);
+ if (reply.error())
+ return;
+
+ auto &root = reply.data();
+ const JSONNode &conversations = root["conversations"].as_array();
+
+ for (auto &it : conversations) {
+ CMStringA szSkypename = it["id"].as_mstring();
+ int iUserType = atoi(szSkypename);
+ MCONTACT hContact = FindContact(szSkypename);
+
+ switch (iUserType) {
+ case 19:
+ {
+ auto &props = it["threadProperties"];
+ CMStringA szType = props["productThreadType"].as_mstring(), szChatType;
+
+ int idx = szSkypename.ReverseFind('@');
+ if (idx != -1)
+ szChatType = szSkypename.Mid(idx + 1);
+
+ if (szType == "Chat" || szChatType == "thread.skype") {
+ auto *si = StartChatRoom(it["id"].as_mstring(), props["topic"].as_mstring(), props["version"].as_string().c_str());
+ for (auto &cc : props["members"])
+ AddChatContact(si, cc.as_mstring(), L"Admin");
+ }
+ else if (szType == "OneToOneChat") {
+ hContact = FindContact(it["properties"]["addedBy"].as_string().c_str());
+ if (hContact)
+ setString(hContact, "ChatId", szSkypename);
+ }
+ }
+ FetchMissingHistory(it, hContact);
+ break;
+
+ case 8:
+ case 2:
+ CMStringA szChatId(it["properties"]["onetoonethreadid"].as_mstring());
+ if (!szChatId.IsEmpty() && hContact) {
+ if (szChatId.Left(3) != "19:")
+ szChatId.Insert(0, "19:");
+ setString(hContact, "ChatId", szChatId);
+ }
+
+ FetchMissingHistory(it, hContact);
+ }
+ }
+
+ m_bHistorySynced = true;
+}
+
+void CTeamsProto::RefreshConversations()
+{
+ auto *pReq = new AsyncHttpRequest(REQUEST_GET, HOST_CHATS, "/users/ME/conversations", &CTeamsProto::OnSyncConversations);
+ pReq << INT_PARAM("startTime", 0) << INT_PARAM("pageSize", 100)
+ << CHAR_PARAM("view", "msnp24Equivalent") << CHAR_PARAM("targetType", "Passport|Skype|Lync|Thread|PSTN|Agent");
+
+ PushRequest(pReq);
+}
+
+//////////////////////////////////////////////////////////////////////////////////////////
+
+void CTeamsProto::GetServerHistory(MCONTACT hContact, int pageSize, int64_t timestamp, bool bOperative)
+{
+ CMStringA szChatId(getMStringA(hContact, "ChatId"));
+ if (szChatId.IsEmpty())
+ szChatId = getId(hContact);
+
+ auto *pReq = new AsyncHttpRequest(REQUEST_GET, HOST_CHATS, "/users/ME/conversations/" + mir_urlEncode(szChatId) + "/messages", &CTeamsProto::OnGetServerHistory);
+ pReq->hContact = hContact;
+ if (bOperative)
+ pReq->pUserInfo = this;
+
+ pReq << INT64_PARAM("startTime", timestamp) << INT_PARAM("pageSize", pageSize)
+ << CHAR_PARAM("view", "msnp24Equivalent") << CHAR_PARAM("targetType", "Passport|Skype|Lync|Thread");
+
+ PushRequest(pReq);
+}
+
+void CTeamsProto::OnGetServerHistory(MHttpResponse *response, AsyncHttpRequest *pRequest)
+{
+ TeamsReply reply(response);
+ if (reply.error())
+ return;
+
+ auto &root = reply.data();
+ const JSONNode &metadata = root["_metadata"];
+
+ int totalCount = metadata["totalCount"].as_int();
+ std::string syncState = metadata["syncState"].as_string();
+
+ bool bOperative = pRequest->pUserInfo != 0;
+ bool bSetLastTime = false;
+
+ int64_t lastMsgTime = 0; // max timestamp on this page
+
+ auto &conv = root["messages"];
+ for (auto it = conv.rbegin(); it != conv.rend(); ++it) {
+ auto &message = *it;
+ CMStringA szId = message["id"].as_mstring();
+ int64_t id = _atoi64(szId);
+ if (id > lastMsgTime) {
+ bSetLastTime = true;
+ lastMsgTime = id;
+ }
+
+ int iUserType;
+ CMStringA szMessageId(getMessageId(message));
+ CMStringA szChatId = UrlToSkypeId(message["conversationLink"].as_mstring(), &iUserType);
+ CMStringA szFrom = UrlToSkypeId(message["from"].as_mstring());
+
+ DB::EventInfo dbei(db_event_getById(m_szModuleName, szMessageId));
+ dbei.hContact = pRequest->hContact;
+ dbei.szModule = m_szModuleName;
+ dbei.szId = szMessageId;
+ dbei.bSent = IsMe(szFrom);
+ dbei.bMsec = dbei.bUtf = true;
+ dbei.iTimestamp = _wtoi64(message["id"].as_mstring());
+
+ if (iUserType == 19) {
+ dbei.szUserId = szFrom;
+
+ CMStringA szType(message["messagetype"].as_mstring());
+ if (szType.Left(15) == "ThreadActivity/")
+ continue;
+ }
+
+ if (!bOperative && !dbei.getEvent())
+ dbei.bRead = true;
+
+ if (ParseMessage(message, dbei)) {
+ if (dbei)
+ db_event_edit(dbei.getEvent(), &dbei, true);
+ else
+ db_event_add(pRequest->hContact, &dbei);
+ }
+ }
+
+ if (bSetLastTime && lastMsgTime > getLastTime(pRequest->hContact))
+ setLastTime(pRequest->hContact, lastMsgTime);
+
+ if (totalCount >= 99 || conv.size() >= 99)
+ GetServerHistory(pRequest->hContact, 100, lastMsgTime, pRequest->pUserInfo != 0);
+}
+
+INT_PTR CTeamsProto::SvcLoadHistory(WPARAM hContact, LPARAM)
+{
+ GetServerHistory(hContact, 100, 0, false);
+ return 0;
+}
+
+//////////////////////////////////////////////////////////////////////////////////////////
+
+INT_PTR CTeamsProto::SvcEmptyHistory(WPARAM hContact, LPARAM flags)
+{
+ if (flags & CDF_DEL_HISTORY)
+ PushRequest(new AsyncHttpRequest(REQUEST_DELETE, HOST_CHATS, "/users/ME/conversations/" + mir_urlEncode(getId(hContact)) + "/messages"));
+
+ return 0;
+}
diff --git a/protocols/Teams/src/teams_http.cpp b/protocols/Teams/src/teams_http.cpp
new file mode 100644
index 0000000000..7ac6d3be2d
--- /dev/null
+++ b/protocols/Teams/src/teams_http.cpp
@@ -0,0 +1,198 @@
+/*
+Copyright (c) 2025 Miranda NG team (https://miranda-ng.org)
+
+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 version 2
+of the License.
+
+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"
+
+AsyncHttpRequest::AsyncHttpRequest(int type, SkypeHost host, LPCSTR url, MTHttpRequestHandler pFunc) :
+ m_host(host)
+{
+ switch (host) {
+ case HOST_API: m_szUrl = "api.skype.com"; break;
+ case HOST_CONTACTS: m_szUrl = "contacts.skype.com/contacts/v2"; break;
+ case HOST_LOGIN: m_szUrl = "login.microsoftonline.com"; break;
+ case HOST_TEAMS: m_szUrl = TEAMS_BASE_HOST; break;
+ case HOST_TEAMS_API: m_szUrl = TEAMS_BASE_HOST "/api/mt/beta"; break;
+ case HOST_CHATS: m_szUrl = TEAMS_BASE_HOST "/api/chatsvc/consumer/v1"; break;
+ case HOST_GROUPS: m_szUrl = TEAMS_BASE_HOST "/api/groups/v1"; break;
+ case HOST_PRESENCE: m_szUrl = "presence." TEAMS_BASE_HOST "/v1"; break;
+ }
+
+ AddHeader("User-Agent", TEAMS_USER_AGENT);
+
+ if (url)
+ m_szUrl.Append(url);
+ m_pFunc = pFunc;
+ flags = NLHRF_HTTP11 | NLHRF_SSL | NLHRF_DUMPASTEXT;
+ requestType = type;
+}
+
+void AsyncHttpRequest::AddAuthentication(CTeamsProto *ppro)
+{
+ AddHeader("Authentication", CMStringA("skypetoken=") + ppro->m_szSkypeToken);
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+void CTeamsProto::StartQueue()
+{
+ if (!m_isTerminated)
+ return;
+
+ m_isTerminated = false;
+ if (m_hRequestQueueThread == nullptr)
+ ForkThread(&CTeamsProto::WorkerThread);
+}
+
+void CTeamsProto::StopQueue()
+{
+ m_isTerminated = true;
+
+ if (m_hRequestQueueThread)
+ m_hRequestQueueEvent.Set();
+}
+
+void CTeamsProto::PushRequest(AsyncHttpRequest *request)
+{
+ if (m_isTerminated)
+ return;
+ {
+ mir_cslock lock(m_requestQueueLock);
+ m_requests.insert(request);
+ }
+ m_hRequestQueueEvent.Set();
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+MHttpResponse* CTeamsProto::DoSend(AsyncHttpRequest *pReq)
+{
+ if (pReq->m_host != HOST_OTHER)
+ pReq->m_szUrl.Insert(0, ((pReq->flags & NLHRF_SSL) ? "https://" : "http://"));
+
+ if (!pReq->m_szParam.IsEmpty()) {
+ switch (pReq->requestType) {
+ case REQUEST_PUT:
+ case REQUEST_POST:
+ if (!pReq->FindHeader("Content-Type")) {
+ if (pReq->m_szParam[0] == '[' || pReq->m_szParam[0] == '{')
+ pReq->AddHeader("Content-Type", "application/json");
+ else
+ pReq->AddHeader("Content-Type", "application/x-www-form-urlencoded");
+ }
+ }
+ }
+
+ pReq->AddHeader("X-MS-Client-Consumer-Type", "teams4life");
+
+ switch (pReq->m_host) {
+ case HOST_CHATS:
+ case HOST_CONTACTS:
+ pReq->AddAuthentication(this);
+ pReq->AddHeader("Accept", "application/json");
+ pReq->AddHeader("X-Stratus-Caller", TEAMS_CLIENTINFO_NAME);
+ pReq->AddHeader("X-Stratus-Request", "abcd1234");
+ pReq->AddHeader("Origin", "https://teams.live.com");
+ pReq->AddHeader("Referer", "https://teams.live.com/");
+ pReq->AddHeader("ms-ic3-product", "tfl");
+ pReq->AddHeader("ms-ic3-additional-product", "Sfl");
+ break;
+
+ case HOST_GROUPS:
+ case HOST_TEAMS_API:
+ pReq->AddHeader("X-MS-Client-Type", "maglev");
+ pReq->AddHeader("Origin", "https://teams.live.com");
+ pReq->AddHeader("Referer", "https://teams.live.com/v2/");
+ pReq->AddHeader("Cookie", mir_urlEncode(m_szApiCookie));
+ __fallthrough;
+
+ case HOST_TEAMS:
+ if (!pReq->FindHeader("Authorization"))
+ pReq->AddHeader("Authorization", "Bearer " + m_szAccessToken);
+ if (m_szSkypeToken)
+ pReq->AddHeader("X-Skypetoken", m_szSkypeToken);
+ pReq->AddHeader("Accept", "application/json");
+ pReq->AddHeader("ms-ic3-product", "tfl");
+ pReq->AddHeader("ms-ic3-additional-product", "Sfl");
+ break;
+
+ case HOST_API:
+ if (m_szSkypeToken)
+ pReq->AddHeader("X-Skypetoken", m_szSkypeToken);
+ pReq->AddHeader("Accept", "application/json");
+ break;
+
+ case HOST_PRESENCE:
+ pReq->flags |= NLHRF_REDIRECT;
+
+ if (m_szSkypeToken)
+ pReq->AddHeader("X-Skypetoken", m_szSkypeToken);
+
+ pReq->AddHeader("Accept", "application/json");
+ pReq->AddHeader("x-ms-client-user-agent", "Teams-V2-Desktop");
+ pReq->AddHeader("x-ms-correlation-id", "1");
+ pReq->AddHeader("x-ms-client-version", TEAMS_CLIENTINFO_VERSION);
+ pReq->AddHeader("x-ms-endpoint-id", m_szEndpoint);
+ break;
+
+ case HOST_LOGIN:
+ #ifndef _DEBUG
+ pReq->flags |= NLHRF_NODUMP;
+ #endif
+ break;
+ }
+
+ debugLogA("Send request to %s", pReq->m_szUrl.c_str());
+
+ return Netlib_HttpTransaction(m_hNetlibUser, pReq);
+}
+
+void CTeamsProto::Execute(AsyncHttpRequest *item)
+{
+ NLHR_PTR response(DoSend(item));
+ if (item->m_pFunc != nullptr)
+ (this->*item->m_pFunc)(response, item);
+ m_requests.remove(item);
+ delete item;
+}
+
+void CTeamsProto::WorkerThread(void*)
+{
+ m_hRequestQueueThread = GetCurrentThread();
+
+ while (true) {
+ m_hRequestQueueEvent.Wait();
+ if (m_isTerminated)
+ break;
+
+ while (true) {
+ AsyncHttpRequest *item = nullptr;
+ {
+ mir_cslock lock(m_requestQueueLock);
+
+ if (m_requests.getCount() == 0)
+ break;
+
+ item = m_requests[0];
+ m_requests.remove(0);
+ }
+ if (item != nullptr)
+ Execute(item);
+ }
+ }
+
+ m_hRequestQueueThread = nullptr;
+}
diff --git a/protocols/Teams/src/teams_login.cpp b/protocols/Teams/src/teams_login.cpp
new file mode 100644
index 0000000000..b676af3641
--- /dev/null
+++ b/protocols/Teams/src/teams_login.cpp
@@ -0,0 +1,254 @@
+/*
+Copyright (c) 2025 Miranda NG team (https://miranda-ng.org)
+
+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 version 2
+of the License.
+
+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"
+
+#define TEAMS_OAUTH_RESOURCE "https://api.spaces.skype.com"
+#define TEAMS_OAUTH_SCOPE "service::api.fl.teams.microsoft.com::MBI_SSL"
+#define TEAMS_SKYPETOKEN_SCOPE "service::api.fl.spaces.skype.com::MBI_SSL"
+#define TEAMS_PERSONAL_TENANT_ID "9188040d-6c67-4c5b-b112-36a304b66dad"
+#define SCOPE_SUFFIX " openid profile offline_access"
+
+void CTeamsProto::LoginError()
+{
+ ProtoBroadcastAck(0, ACKTYPE_LOGIN, ACKRESULT_FAILED, NULL, 1001);
+ SetStatus(ID_STATUS_OFFLINE);
+
+ if (m_iLoginExpires) {
+ m_impl.m_loginPoll.StopSafe();
+ m_iLoginExpires = 0;
+ }
+}
+
+void CTeamsProto::LoggedIn()
+{
+ int oldStatus = m_iStatus;
+ m_iStatus = m_iDesiredStatus;
+ ProtoBroadcastAck(0, ACKTYPE_STATUS, ACKRESULT_SUCCESS, (HANDLE)oldStatus, m_iStatus);
+
+ SetServerStatus(m_iStatus);
+
+ ReceiveAvatar(0);
+ RefreshContactsInfo();
+ RefreshConversations();
+
+ GetProfileInfo(0);
+
+ PushRequest(new AsyncHttpRequest(REQUEST_POST, HOST_TEAMS_API, "/imageauth/cookie", &CTeamsProto::OnReceiveApiCookie));
+
+ StartTrouter();
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+void CTeamsProto::OnReceiveDevicePoll(MHttpResponse *response, AsyncHttpRequest *)
+{
+ JsonReply reply(response);
+ if (!reply) {
+ if (!strstr(response->body, "\"error\":\"authorization_pending\""))
+ LoginError();
+ return;
+ }
+
+ if (m_iLoginExpires) {
+ m_impl.m_loginPoll.StopSafe();
+ m_iLoginExpires = 0;
+ }
+ m_szDeviceCode.Empty();
+
+ auto &root = reply.data();
+ setWString(DBKEY_RTOKEN, root["refresh_token"].as_mstring());
+
+ OauthRefreshServices();
+}
+
+void CTeamsProto::LoginPoll()
+{
+ if (time(0) >= m_iLoginExpires) {
+ LoginError();
+ return;
+ }
+
+ auto *pReq = new AsyncHttpRequest(REQUEST_POST, HOST_LOGIN, "/common/oauth2/token", &CTeamsProto::OnReceiveDevicePoll);
+ pReq->AddHeader("Cookie", m_szDeviceCookie);
+ pReq << CHAR_PARAM("client_id", TEAMS_CLIENT_ID) << CHAR_PARAM("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
+ << CHAR_PARAM("code", m_szDeviceCode);
+ PushRequest(pReq);
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+const wchar_t wszLoginMessage[] =
+LPGENW("To login into Teams you need to open '%S' in a browser and select your Teams account there.") L"\r\n\r\n"
+LPGENW("Enter the following code then: %s.") L"\r\n\r\n"
+LPGENW("Click Proceed to copy that code to clipboard and launch a browser");
+
+class CDeviceCodeDlg : public CTeamsDlgBase
+{
+ bool bSucceeded = false;
+
+public:
+ CDeviceCodeDlg(CTeamsProto *ppro) :
+ CTeamsDlgBase(ppro, IDD_DEVICECODE)
+ {}
+
+ bool OnInitDialog() override
+ {
+ CMStringW wszText(FORMAT, TranslateW(wszLoginMessage), m_proto->m_szVerificationUrl.c_str(), m_proto->m_wszUserCode.c_str());
+ SetDlgItemTextW(m_hwnd, IDC_TEXT, wszText);
+ return true;
+ }
+
+ bool OnApply() override
+ {
+ bSucceeded = true;
+ Utils_OpenUrl(m_proto->m_szVerificationUrl);
+ return true;
+ }
+
+ void OnDestroy() override
+ {
+ if (!bSucceeded)
+ m_proto->LoginError();
+ }
+};
+
+static void CALLBACK LaunchDialog(void *param)
+{
+ (new CDeviceCodeDlg((CTeamsProto *)param))->Show();
+}
+
+void CTeamsProto::OnReceiveDeviceToken(MHttpResponse *response, AsyncHttpRequest *)
+{
+ JsonReply reply(response);
+ if (!reply) {
+ LoginError();
+ return;
+ }
+
+ auto &root = reply.data();
+ m_wszUserCode = root["user_code"].as_mstring();
+ m_szDeviceCode = root["device_code"].as_mstring();
+ m_szVerificationUrl = root["verification_url"].as_mstring();
+ m_iLoginExpires = time(0) + root["expires_in"].as_int();
+ m_impl.m_loginPoll.StartSafe(root["interval"].as_int() * 1000);
+ m_szDeviceCookie = response->GetCookies();
+
+ Utils_ClipboardCopy(MClipUnicode(m_wszUserCode));
+ CallFunctionAsync(LaunchDialog, this);
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+void CTeamsProto::OnReceiveSkypeToken(MHttpResponse *response, AsyncHttpRequest *)
+{
+ JsonReply reply(response);
+ if (!reply) {
+ LoginError();
+ return;
+ }
+
+ auto &token = reply.data()["skypeToken"];
+ m_szSkypeToken = token["skypetoken"].as_mstring();
+
+ m_szOwnSkypeId = token["skypeid"].as_mstring();
+ setString(DBKEY_ID, m_szOwnSkypeId);
+
+ LoggedIn();
+}
+
+void CTeamsProto::OnRefreshAccessToken(MHttpResponse *response, AsyncHttpRequest *)
+{
+ JsonReply reply(response);
+ if (!reply) {
+ LoginError();
+ return;
+ }
+
+ auto &root = reply.data();
+ m_szAccessToken = root["access_token"].as_mstring();
+ setWString(DBKEY_RTOKEN, root["refresh_token"].as_mstring());
+}
+
+void CTeamsProto::OnRefreshSkypeToken(MHttpResponse *response, AsyncHttpRequest *)
+{
+ JsonReply reply(response);
+ if (!reply) {
+ LoginError();
+ return;
+ }
+
+ auto &root = reply.data();
+ CMStringA szAccessToken(root["access_token"].as_mstring());
+
+ auto *pReq = new AsyncHttpRequest(REQUEST_POST, HOST_TEAMS, "/api/auth/v1.0/authz/consumer", &CTeamsProto::OnReceiveSkypeToken);
+ pReq->AddHeader("Authorization", "Bearer " + szAccessToken);
+ PushRequest(pReq);
+}
+
+void CTeamsProto::OnRefreshSubstrate(MHttpResponse *response, AsyncHttpRequest *)
+{
+ JsonReply reply(response);
+ if (!reply) {
+ LoginError();
+ return;
+ }
+
+ auto &root = reply.data();
+ m_szSubstrateToken = root["access_token"].as_mstring();
+}
+
+void CTeamsProto::RefreshToken(const char *pszScope, AsyncHttpRequest::MTHttpRequestHandler pFunc)
+{
+ auto *pReq = new AsyncHttpRequest(REQUEST_POST, HOST_LOGIN, "/" TEAMS_PERSONAL_TENANT_ID "/oauth2/v2.0/token", pFunc);
+ pReq << CHAR_PARAM("scope", pszScope) << CHAR_PARAM("client_id", TEAMS_CLIENT_ID)
+ << CHAR_PARAM("grant_type", "refresh_token") << CHAR_PARAM("refresh_token", getMStringA(DBKEY_RTOKEN));
+ PushRequest(pReq);
+}
+
+void CTeamsProto::OauthRefreshServices()
+{
+ RefreshToken(TEAMS_SKYPETOKEN_SCOPE SCOPE_SUFFIX, &CTeamsProto::OnRefreshSkypeToken);
+ RefreshToken("https://substrate.office.com/M365.Access" SCOPE_SUFFIX, &CTeamsProto::OnRefreshSubstrate);
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+// module entry point
+
+void CTeamsProto::Login()
+{
+ // set plugin status to connect
+ int oldStatus = m_iStatus;
+ m_iStatus = ID_STATUS_CONNECTING;
+ ProtoBroadcastAck(0, ACKTYPE_STATUS, ACKRESULT_SUCCESS, (HANDLE)oldStatus, m_iStatus);
+
+ // launch http queue
+ StartQueue();
+
+ // if refresh token doesn't exist, perform a device code authentication
+ m_szAccessToken = getMStringA(DBKEY_RTOKEN);
+ if (m_szAccessToken.IsEmpty()) {
+ auto *pReq = new AsyncHttpRequest(REQUEST_POST, HOST_LOGIN, "/common/oauth2/devicecode", &CTeamsProto::OnReceiveDeviceToken);
+ pReq << CHAR_PARAM("client_id", TEAMS_CLIENT_ID) << CHAR_PARAM("resource", TEAMS_OAUTH_RESOURCE);
+ PushRequest(pReq);
+ }
+ // or use a refresh token otherwise
+ else {
+ RefreshToken(TEAMS_OAUTH_SCOPE SCOPE_SUFFIX, &CTeamsProto::OnRefreshAccessToken);
+ OauthRefreshServices();
+ }
+}
diff --git a/protocols/Teams/src/teams_menus.cpp b/protocols/Teams/src/teams_menus.cpp
new file mode 100644
index 0000000000..e46d2174e1
--- /dev/null
+++ b/protocols/Teams/src/teams_menus.cpp
@@ -0,0 +1,92 @@
+/*
+Copyright (c) 2025 Miranda NG team (https://miranda-ng.org)
+
+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 version 2
+of the License.
+
+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"
+
+HGENMENU CTeamsProto::ContactMenuItems[CMI_MAX];
+
+int CTeamsProto::OnPrebuildContactMenu(WPARAM hContact, LPARAM)
+{
+ if (!hContact)
+ return 0;
+
+ if (m_iStatus < ID_STATUS_ONLINE)
+ return 0;
+
+ if (isChatRoom(hContact))
+ return 0;
+
+ bool isCtrlPressed = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
+ bool isAuthNeed = getByte(hContact, "Auth", 0) > 0;
+ bool isGrantNeed = getByte(hContact, "Grant", 0) > 0;
+ bool isBlocked = getBool(hContact, "IsBlocked", false);
+
+ Menu_ShowItem(GetMenuItem(PROTO_MENU_REQ_AUTH), isCtrlPressed || isAuthNeed);
+ Menu_ShowItem(GetMenuItem(PROTO_MENU_GRANT_AUTH), isCtrlPressed || isGrantNeed);
+
+ Menu_ShowItem(ContactMenuItems[CMI_BLOCK], true);
+ Menu_ShowItem(ContactMenuItems[CMI_UNBLOCK], isCtrlPressed || isBlocked);
+ return 0;
+}
+
+int CTeamsProto::PrebuildContactMenu(WPARAM hContact, LPARAM lParam)
+{
+ for (auto &it : ContactMenuItems)
+ Menu_ShowItem(it, false);
+ CTeamsProto *proto = CMPlugin::getInstance(hContact);
+ return proto ? proto->OnPrebuildContactMenu(hContact, lParam) : 0;
+}
+
+void CTeamsProto::InitMenus()
+{
+ HookEvent(ME_CLIST_PREBUILDCONTACTMENU, &CTeamsProto::PrebuildContactMenu);
+
+ CMenuItem mi(&g_plugin);
+ mi.flags = CMIF_UNICODE;
+
+ mi.pszService = MODULENAME "/BlockContact";
+ mi.name.w = LPGENW("Block contact");
+ mi.position = CMI_POSITION + CMI_BLOCK;
+ mi.hIcolibItem = g_plugin.getIconHandle(IDI_BLOCKUSER);
+ SET_UID(mi, 0xc6169b8f, 0x53ab, 0x4242, 0xbe, 0x90, 0xe2, 0x4a, 0xa5, 0x73, 0x88, 0x32);
+ ContactMenuItems[CMI_BLOCK] = Menu_AddContactMenuItem(&mi);
+ CreateServiceFunction(mi.pszService, GlobalService<&CTeamsProto::BlockContact>);
+
+ mi.pszService = MODULENAME "/UnblockContact";
+ mi.name.w = LPGENW("Unblock contact");
+ mi.position = CMI_POSITION + CMI_UNBLOCK;
+ mi.hIcolibItem = g_plugin.getIconHandle(IDI_UNBLOCKUSER);
+ SET_UID(mi, 0x88542f43, 0x7448, 0x48d0, 0x81, 0xa3, 0x26, 0x0, 0x4f, 0x37, 0xee, 0xe0);
+ ContactMenuItems[CMI_UNBLOCK] = Menu_AddContactMenuItem(&mi);
+ CreateServiceFunction(mi.pszService, GlobalService<&CTeamsProto::UnblockContact>);
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+// Protocol's menu in the status bar
+
+void CTeamsProto::OnBuildProtoMenu()
+{
+ CMenuItem mi(&g_plugin);
+ mi.root = Menu_GetProtocolRoot(this);
+
+ mi.pszService = "/CreateNewChat";
+ CreateProtoService(mi.pszService, &CTeamsProto::SvcCreateChat);
+ mi.name.a = LPGEN("Create new chat");
+ mi.position = 200000;
+ mi.hIcolibItem = g_plugin.getIconHandle(IDI_CONFERENCE);
+ Menu_AddProtoMenuItem(&mi, m_szModuleName);
+}
diff --git a/protocols/Teams/src/teams_menus.h b/protocols/Teams/src/teams_menus.h
new file mode 100644
index 0000000000..04b87c6e83
--- /dev/null
+++ b/protocols/Teams/src/teams_menus.h
@@ -0,0 +1,30 @@
+/*
+Copyright (c) 2015-25 Miranda NG team (https://miranda-ng.org)
+
+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 version 2
+of the License.
+
+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/>.
+*/
+
+#ifndef _SKYPE_MENUS_H_
+#define _SKYPE_MENUS_H_
+
+#define CMI_POSITION -201001000
+
+enum
+{
+ CMI_BLOCK,
+ CMI_UNBLOCK,
+ CMI_MAX // this item shall be the last one
+};
+
+#endif //_SKYPE_MENUS_H_ \ No newline at end of file
diff --git a/protocols/Teams/src/teams_messages.cpp b/protocols/Teams/src/teams_messages.cpp
new file mode 100644
index 0000000000..c2c4ad2473
--- /dev/null
+++ b/protocols/Teams/src/teams_messages.cpp
@@ -0,0 +1,337 @@
+/*
+Copyright (c) 2015-25 Miranda NG team (https://miranda-ng.org)
+
+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 version 2
+of the License.
+
+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"
+
+/////////////////////////////////////////////////////////////////////////////////////////
+// MESSAGE SENDING
+
+void CTeamsProto::OnMessageSent(MHttpResponse *response, AsyncHttpRequest *pRequest)
+{
+ // to delete it in any case
+ std::unique_ptr<COwnMessage> pMessage((COwnMessage *)pRequest->pUserInfo);
+
+ MCONTACT hContact = pRequest->hContact;
+ if (response == nullptr) {
+ ProtoBroadcastAck(hContact, ACKTYPE_MESSAGE, ACKRESULT_FAILED, pRequest->pUserInfo, (LPARAM)TranslateT("Network error!"));
+ return;
+ }
+
+ if (response->resultCode == 201) {
+ JsonReply reply(response);
+ auto &pRoot = reply.data();
+
+ if (pMessage) {
+ if (!Contact::IsGroupChat(hContact)) {
+ pMessage->iTimestamp = _wtoi64(pRoot["OriginalArrivalTime"].as_mstring());
+
+ CMStringA szMsgId(FORMAT, "%lld", pMessage->hClientMessageId);
+ ProtoBroadcastAck(hContact, ACKTYPE_MESSAGE, ACKRESULT_SUCCESS, (HANDLE)pMessage->hMessage, (LPARAM)szMsgId.c_str());
+ }
+
+ mir_cslock lck(m_lckOutMessagesList);
+ m_OutMessages.remove(pMessage.get());
+ }
+ }
+ else {
+ std::string strError = Translate("Unknown error!");
+
+ if (!response->body.IsEmpty()) {
+ JSONNode jRoot = JSONNode::parse(response->body);
+ const JSONNode &jErr = jRoot["errorCode"];
+ if (jErr)
+ strError = jErr.as_string();
+ }
+
+ ProtoBroadcastAck(hContact, ACKTYPE_MESSAGE, ACKRESULT_FAILED, pRequest->pUserInfo, _A2T(strError.c_str()));
+ }
+}
+
+// outcoming message flow
+
+int CTeamsProto::SendServerMsg(MCONTACT hContact, const char *szMessage, int64_t existingMsgId)
+{
+ if (!IsOnline())
+ return -1;
+
+ CMStringA str(szMessage);
+ bool bRich = AddBbcodes(str);
+ m_iMessageId++;
+
+ CMStringA szChatId(getMStringA(hContact, "ChatId"));
+ if (szChatId.IsEmpty())
+ szChatId = getId(hContact);
+
+ CMStringA szUrl = "/users/ME/conversations/" + mir_urlEncode(szChatId) + "/messages";
+ if (existingMsgId)
+ szUrl.AppendFormat("/%lld", existingMsgId);
+
+ JSONNode node;
+ node << CHAR_PARAM("messagetype", bRich ? "RichText" : "Text") << CHAR_PARAM("contenttype", "text");
+ if (strncmp(str, "/me ", 4) == 0)
+ node << CHAR_PARAM("content", m_szSkypename + " " + str);
+ else
+ node << CHAR_PARAM("content", str);
+
+ COwnMessage *pOwnMessage = nullptr;
+ if (!existingMsgId) {
+ int64_t iRandomId = getRandomId();
+ node << INT64_PARAM("clientmessageid", iRandomId);
+
+ mir_cslock lck(m_lckOutMessagesList);
+ m_OutMessages.insert(pOwnMessage = new COwnMessage(m_iMessageId, iRandomId));
+ }
+
+ AsyncHttpRequest *pReq = new AsyncHttpRequest(existingMsgId ? REQUEST_PUT : REQUEST_POST, HOST_CHATS, szUrl, &CTeamsProto::OnMessageSent);
+ pReq->hContact = hContact;
+ pReq->pUserInfo = pOwnMessage;
+ pReq->m_szParam = node.write().c_str();
+ PushRequest(pReq);
+
+ return m_iMessageId;
+}
+
+// preparing message/action to be written into db
+int CTeamsProto::OnPreCreateMessage(WPARAM, LPARAM lParam)
+{
+ MessageWindowEvent *evt = (MessageWindowEvent*)lParam;
+ if (mir_strcmp(Proto_GetBaseAccountName(evt->hContact), m_szModuleName))
+ return 0;
+
+ auto &dbei = evt->dbei;
+ if (dbei->szId) {
+ int64_t msgId = _atoi64(dbei->szId);
+ for (auto &it : m_OutMessages) {
+ if (it->hClientMessageId == msgId) {
+ dbei->bMsec = true;
+ dbei->iTimestamp = it->iTimestamp;
+ } } }
+
+ return 0;
+}
+
+/* MESSAGE EVENT */
+
+bool CTeamsProto::ParseMessage(const JSONNode &node, DB::EventInfo &dbei)
+{
+ auto &pContent = node["content"];
+ if (!pContent) {
+LBL_Deleted:
+ if (dbei)
+ db_event_delete(dbei.getEvent());
+ return false;
+ }
+
+ CMStringW wszContent = pContent.as_mstring();
+ if (wszContent.IsEmpty())
+ goto LBL_Deleted;
+
+ std::string strMessageType = node["messagetype"].as_string();
+ if (strMessageType == "RichText/Media_GenericFile" || strMessageType == "RichText/Media_Video" || strMessageType == "RichText/UriObject" ) {
+ ProcessFileRecv(dbei.hContact, node["content"].as_string().c_str(), dbei);
+ return false;
+ }
+ if (strMessageType == "RichText/Contacts") {
+ ProcessContactRecv(dbei.hContact, node["content"].as_string().c_str(), dbei);
+ return false;
+ }
+
+ if (strMessageType == "Text") {
+ dbei.eventType = EVENTTYPE_MESSAGE;
+ }
+ else if (strMessageType == "RichText/Html" || strMessageType == "RichText") {
+ wszContent = RemoveHtml(wszContent);
+ dbei.eventType = EVENTTYPE_MESSAGE;
+ }
+ else if (strMessageType == "RichText/Media_Album")
+ return false;
+
+ replaceStr(dbei.pBlob, mir_utf8encodeW(wszContent));
+ dbei.cbBlob = (uint32_t)mir_strlen(dbei.pBlob);
+ return true;
+}
+
+void CTeamsProto::ProcessNewMessage(const JSONNode &node)
+{
+ int iUserType;
+ UrlToSkypeId(node["conversationLink"].as_string().c_str(), &iUserType);
+
+ int64_t timestamp = _wtoi64(node["id"].as_mstring());
+ CMStringA szMessageId(getMessageId(node));
+ CMStringA szConversationName(UrlToSkypeId(node["conversationLink"].as_string().c_str()));
+ CMStringA szFromSkypename(UrlToSkypeId(node["from"].as_mstring()));
+
+ if (iUserType == 19)
+ if (OnChatEvent(node))
+ return;
+
+ MCONTACT hContact = AddContact(szConversationName, nullptr, true);
+
+ if (m_bHistorySynced && timestamp > getLastTime(hContact))
+ setLastTime(hContact, timestamp);
+
+ std::string strMessageType = node["messagetype"].as_string();
+ if (strMessageType == "Control/Typing") {
+ CallService(MS_PROTO_CONTACTISTYPING, hContact, 30);
+ return;
+ }
+ if (strMessageType == "Control/ClearTyping") {
+ CallService(MS_PROTO_CONTACTISTYPING, hContact, PROTOTYPE_CONTACTTYPING_OFF);
+ return;
+ }
+
+ DB::EventInfo dbei(db_event_getById(m_szModuleName, szMessageId));
+ dbei.hContact = hContact;
+ dbei.iTimestamp = timestamp;
+ dbei.szId = szMessageId;
+ dbei.bUtf = dbei.bMsec = true;
+ dbei.bSent = IsMe(szFromSkypename);
+ if (iUserType == 19)
+ dbei.szUserId = szFromSkypename;
+
+ if (ParseMessage(node, dbei)) {
+ if (dbei)
+ db_event_edit(dbei.getEvent(), &dbei, true);
+ else
+ ProtoChainRecvMsg(hContact, dbei);
+ }
+}
+
+void CTeamsProto::OnMarkRead(MCONTACT hContact, MEVENT hDbEvent)
+{
+ if (IsOnline()) {
+ DB::EventInfo dbei(hDbEvent, false);
+ if (dbei && dbei.szId) {
+ auto *pReq = new AsyncHttpRequest(REQUEST_PUT, HOST_CHATS, "/users/ME/conversations/" + mir_urlEncode(getId(hContact)) + "/properties?name=consumptionhorizon");
+ auto msgTimestamp = _atoi64(dbei.szId);
+
+ JSONNode node(JSON_NODE);
+ node << CHAR_PARAM("consumptionhorizon", CMStringA(::FORMAT, "%lld;%lld;%lld", msgTimestamp, msgTimestamp, msgTimestamp));
+ pReq->m_szParam = node.write().c_str();
+
+ PushRequest(pReq);
+ }
+ }
+}
+
+void CTeamsProto::OnReceiveOfflineFile(DB::EventInfo &dbei, DB::FILE_BLOB &blob)
+{
+ if (auto *ft = (CSkypeTransfer *)blob.getUserInfo()) {
+ blob.setUrl(ft->url);
+ blob.setSize(ft->iFileSize);
+
+ auto &json = dbei.setJson();
+ json << CHAR_PARAM("skft", ft->fileType);
+ if (ft->iHeight != -1)
+ json << INT_PARAM("h", ft->iHeight);
+ if (ft->iWidth != -1)
+ json << INT_PARAM("w", ft->iWidth);
+ delete ft;
+ }
+}
+
+void CTeamsProto::ProcessFileRecv(MCONTACT hContact, const char *szContent, DB::EventInfo &dbei)
+{
+ TiXmlDocument doc;
+ if (0 != doc.Parse(szContent))
+ return;
+
+ auto *xmlRoot = doc.FirstChildElement("URIObject");
+ if (xmlRoot == nullptr)
+ return;
+
+ CSkypeTransfer *ft = new CSkypeTransfer;
+ if (auto *str = xmlRoot->Attribute("doc_id"))
+ ft->docId = str;
+ if (auto *str = xmlRoot->Attribute("uri"))
+ ft->url = str;
+ ft->iWidth = xmlRoot->IntAttribute("width", -1);
+ ft->iHeight = xmlRoot->IntAttribute("heighr", -1);
+ if (auto *str = xmlRoot->Attribute("type"))
+ ft->fileType = str;
+ if (auto *xml = xmlRoot->FirstChildElement("FileSize"))
+ if (auto *str = xml->Attribute("v"))
+ ft->iFileSize = atoi(str);
+ if (auto *xml = xmlRoot->FirstChildElement("OriginalName"))
+ if (auto *str = xml->Attribute("v"))
+ ft->fileName = str;
+
+ if (ft->url.IsEmpty() || ft->fileName.IsEmpty() || ft->iFileSize == 0) {
+ debugLogA("Missing file info: url=<%s> name=<%s> %d", ft->url.c_str(), ft->fileName.c_str(), ft->iFileSize);
+ delete ft;
+ return;
+ }
+
+ int idx = ft->fileType.Find('/');
+ if (idx != -1)
+ ft->fileType = ft->fileType.Left(idx);
+
+ // ordinary file
+ if (ft->fileType == "File.1" || ft->fileType == "Picture.1" || ft->fileType == "Video.1") {
+ MEVENT hEvent;
+ dbei.flags |= DBEF_TEMPORARY | DBEF_JSON;
+ if (dbei) {
+ DB::FILE_BLOB blob(dbei);
+ OnReceiveOfflineFile(dbei, blob);
+ blob.write(dbei);
+ db_event_edit(dbei.getEvent(), &dbei, true);
+ delete ft;
+ hEvent = dbei.getEvent();
+ }
+ else hEvent = ProtoChainRecvFile(hContact, DB::FILE_BLOB(ft, ft->fileName), dbei);
+ }
+ else debugLogA("Invalid or unsupported file type <%s> ignored", ft->fileType.c_str());
+}
+
+void CTeamsProto::ProcessContactRecv(MCONTACT hContact, const char *szContent, DB::EventInfo &dbei)
+{
+ TiXmlDocument doc;
+ if (0 != doc.Parse(szContent))
+ return;
+
+ auto *xmlNode = doc.FirstChildElement("contacts");
+ if (xmlNode == nullptr)
+ return;
+
+ int nCount = 0;
+ for (auto *it : TiXmlEnum(xmlNode)) {
+ UNREFERENCED_PARAMETER(it);
+ nCount++;
+ }
+
+ PROTOSEARCHRESULT **psr = (PROTOSEARCHRESULT**)mir_calloc(sizeof(PROTOSEARCHRESULT*) * nCount);
+
+ nCount = 0;
+ for (auto *xmlContact : TiXmlFilter(xmlNode, "c")) {
+ psr[nCount] = (PROTOSEARCHRESULT*)mir_calloc(sizeof(PROTOSEARCHRESULT));
+ psr[nCount]->cbSize = sizeof(psr);
+ psr[nCount]->id.a = mir_strdup(xmlContact->Attribute("s"));
+ nCount++;
+ }
+
+ if (nCount) {
+ dbei.pBlob = (char*)psr;
+ dbei.cbBlob = nCount;
+
+ ProtoChainRecv(hContact, PSR_CONTACTS, 0, (LPARAM)&dbei);
+ for (int i = 0; i < nCount; i++) {
+ mir_free(psr[i]->id.a);
+ mir_free(psr[i]);
+ }
+ }
+ mir_free(psr);
+}
diff --git a/protocols/Teams/src/teams_options.cpp b/protocols/Teams/src/teams_options.cpp
new file mode 100644
index 0000000000..0f50948446
--- /dev/null
+++ b/protocols/Teams/src/teams_options.cpp
@@ -0,0 +1,99 @@
+/*
+Copyright (c) 2025 Miranda NG team (https://miranda-ng.org)
+
+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 version 2
+of the License.
+
+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 COptionsMainDlg : public CTeamsDlgBase
+{
+ CCtrlEdit m_login, m_group;
+ CCtrlCheck m_autosync, m_usehostname, m_usebb;
+ CCtrlButton btnLogout;
+
+public:
+ COptionsMainDlg(CTeamsProto *proto, int idDialog) :
+ CTeamsDlgBase(proto, idDialog),
+ m_login(this, IDC_LOGIN),
+ m_group(this, IDC_GROUP),
+ m_autosync(this, IDC_AUTOSYNC),
+ m_usehostname(this, IDC_USEHOST),
+ m_usebb(this, IDC_BBCODES),
+ btnLogout(this, IDC_LOGOUT)
+ {
+ CreateLink(m_group, proto->m_wstrCListGroup);
+ CreateLink(m_autosync, proto->m_bAutoHistorySync);
+ CreateLink(m_usehostname, proto->m_bUseHostnameAsPlace);
+ CreateLink(m_usebb, proto->m_bUseBBCodes);
+
+ btnLogout.OnClick = Callback(this, &COptionsMainDlg::onClick_Logout);
+ }
+
+ bool OnInitDialog() override
+ {
+ if (m_proto->getMStringA(DBKEY_RTOKEN).IsEmpty())
+ btnLogout.Disable();
+
+ CMStringA szLogin(m_proto->getMStringA(DBKEY_ID));
+ if (szLogin.IsEmpty())
+ m_login.SetText(TranslateT("<will appear after first login>"));
+ else
+ m_login.SetTextA(szLogin);
+ m_group.SendMsg(EM_LIMITTEXT, 64, 0);
+ return true;
+ }
+
+ bool OnApply() override
+ {
+ ptrW group(m_group.GetText());
+ if (mir_wstrlen(group) > 0 && !Clist_GroupExists(group))
+ Clist_GroupCreate(0, group);
+ return true;
+ }
+
+ void onClick_Logout(CCtrlButton *)
+ {
+ m_proto->delSetting(DBKEY_RTOKEN);
+
+ if (m_proto->IsOnline())
+ m_proto->SetStatus(ID_STATUS_OFFLINE);
+
+ btnLogout.Disable();
+ }
+};
+
+/////////////////////////////////////////////////////////////////////////////////
+
+MWindow CTeamsProto::OnCreateAccMgrUI(MWindow hwndParent)
+{
+ auto *pDlg = new COptionsMainDlg(this, IDD_ACCOUNT_MANAGER);
+ pDlg->SetParent(hwndParent);
+ pDlg->Show();
+ return pDlg->GetHwnd();
+}
+
+int CTeamsProto::OnOptionsInit(WPARAM wParam, LPARAM)
+{
+ OPTIONSDIALOGPAGE odp = { sizeof(odp) };
+ odp.szTitle.w = m_tszUserName;
+ odp.flags = ODPF_BOLDGROUPS | ODPF_UNICODE | ODPF_DONTTRANSLATE;
+ odp.szGroup.w = LPGENW("Network");
+
+ odp.szTab.w = LPGENW("Account");
+ odp.pDialog = new COptionsMainDlg(this, IDD_OPTIONS_MAIN);
+ g_plugin.addOptions(wParam, &odp);
+
+ return 0;
+}
diff --git a/protocols/Teams/src/teams_polling.cpp b/protocols/Teams/src/teams_polling.cpp
new file mode 100644
index 0000000000..11a918034e
--- /dev/null
+++ b/protocols/Teams/src/teams_polling.cpp
@@ -0,0 +1,137 @@
+/*
+Copyright (c) 2015-25 Miranda NG team (https://miranda-ng.org)
+
+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 version 2
+of the License.
+
+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 CTeamsProto::ParsePollData(const char *szData)
+{
+ debugLogA(__FUNCTION__);
+
+ JSONNode data = JSONNode::parse(szData);
+ if (!data)
+ return;
+
+ for (auto &message : data["eventMessages"]) {
+ int eventId = message["id"].as_int();
+ if (eventId > m_iPollingId)
+ m_iPollingId = eventId;
+
+ const JSONNode &resource = message["resource"];
+
+ std::string resourceType = message["resourceType"].as_string();
+ if (resourceType == "NewMessage")
+ ProcessNewMessage(resource);
+ else if (resourceType == "UserPresence")
+ ProcessUserPresence(resource);
+ else if (resourceType == "EndpointPresence")
+ ProcessEndpointPresence(resource);
+ else if (resourceType == "ConversationUpdate")
+ ProcessConversationUpdate(resource);
+ else if (resourceType == "ThreadUpdate")
+ ProcessThreadUpdate(resource);
+ }
+}
+
+void CTeamsProto::ProcessEndpointPresence(const JSONNode &node)
+{
+ debugLogA(__FUNCTION__);
+ std::string selfLink = node["selfLink"].as_string();
+ CMStringA skypename(UrlToSkypeId(selfLink.c_str()));
+
+ MCONTACT hContact = FindContact(skypename);
+ if (hContact == NULL)
+ return;
+
+ const JSONNode &publicInfo = node["publicInfo"];
+ const JSONNode &privateInfo = node["privateInfo"];
+ CMStringA MirVer;
+ if (publicInfo) {
+ std::string skypeNameVersion = publicInfo["skypeNameVersion"].as_string();
+ std::string version = publicInfo["version"].as_string();
+ std::string typ = publicInfo["typ"].as_string();
+ int iTyp = atoi(typ.c_str());
+ switch (iTyp) {
+ case 0:
+ case 1:
+ MirVer.Append("Skype (Web) " + ParseUrl(version.c_str(), "/"));
+ break;
+ case 10:
+ MirVer.Append("Skype (XBOX) " + ParseUrl(skypeNameVersion.c_str(), "/"));
+ break;
+ case 17:
+ MirVer.Append("Skype (Android) " + ParseUrl(skypeNameVersion.c_str(), "/"));
+ break;
+ case 16:
+ MirVer.Append("Skype (iOS) " + ParseUrl(skypeNameVersion.c_str(), "/"));
+ break;
+ case 12:
+ MirVer.Append("Skype (WinRT) " + ParseUrl(skypeNameVersion.c_str(), "/"));
+ break;
+ case 15:
+ MirVer.Append("Skype (WP) " + ParseUrl(skypeNameVersion.c_str(), "/"));
+ break;
+ case 13:
+ MirVer.Append("Skype (OSX) " + ParseUrl(skypeNameVersion.c_str(), "/"));
+ break;
+ case 11:
+ MirVer.Append("Skype (Windows) " + ParseUrl(skypeNameVersion.c_str(), "/"));
+ break;
+ case 14:
+ MirVer.Append("Skype (Linux) " + ParseUrl(skypeNameVersion.c_str(), "/"));
+ break;
+ case 125:
+ MirVer.AppendFormat("Miranda NG Skype %s", version.c_str());
+ break;
+ default:
+ MirVer.Append("Skype (Unknown)");
+ }
+ }
+
+ if (privateInfo != NULL) {
+ std::string epname = privateInfo["epname"].as_string();
+ if (!epname.empty())
+ MirVer.AppendFormat(" [%s]", epname.c_str());
+ }
+
+ setString(hContact, "MirVer", MirVer);
+}
+
+void CTeamsProto::ProcessUserPresence(const JSONNode &node)
+{
+ debugLogA(__FUNCTION__);
+
+ std::string selfLink = node["selfLink"].as_string();
+ std::string status = node["availability"].as_string();
+ CMStringA skypename = UrlToSkypeId(selfLink.c_str());
+
+ if (!skypename.IsEmpty()) {
+ if (IsMe(skypename)) {
+ int iNewStatus = SkypeToMirandaStatus(status.c_str());
+ if (iNewStatus == ID_STATUS_OFFLINE) return;
+ int old_status = m_iStatus;
+ m_iDesiredStatus = iNewStatus;
+ m_iStatus = iNewStatus;
+ if (old_status != iNewStatus)
+ ProtoBroadcastAck(NULL, ACKTYPE_STATUS, ACKRESULT_SUCCESS, (HANDLE)old_status, iNewStatus);
+ }
+ else {
+ MCONTACT hContact = FindContact(skypename);
+ if (hContact != NULL)
+ SetContactStatus(hContact, SkypeToMirandaStatus(status.c_str()));
+ }
+ }
+}
diff --git a/protocols/Teams/src/teams_popups.cpp b/protocols/Teams/src/teams_popups.cpp
new file mode 100644
index 0000000000..59cca9e937
--- /dev/null
+++ b/protocols/Teams/src/teams_popups.cpp
@@ -0,0 +1,100 @@
+/*
+Copyright (c) 2025 Miranda NG team (https://miranda-ng.org)
+
+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 version 2
+of the License.
+
+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 CTeamsProto::InitPopups()
+{
+ wchar_t desc[256];
+ char name[256];
+
+ POPUPCLASS ppc = {};
+ ppc.flags = PCF_UNICODE;
+ ppc.pszName = name;
+ ppc.pszDescription.w = desc;
+
+ mir_snwprintf(desc, L"%s/%s", m_tszUserName, TranslateT("Notifications"));
+ mir_snprintf(name, "%s_%s", m_szModuleName, "Notification");
+ ppc.hIcon = g_plugin.getIcon(IDI_NOTIFY);
+ ppc.iSeconds = 5;
+ m_PopupClasses.insert(Popup_RegisterClass(&ppc));
+
+ mir_snwprintf(desc, L"%s/%s", m_tszUserName, TranslateT("Errors"));
+ mir_snprintf(name, "%s_%s", m_szModuleName, "Error");
+ ppc.hIcon = g_plugin.getIcon(IDI_ERRORICON);
+ ppc.iSeconds = -1;
+ m_PopupClasses.insert(Popup_RegisterClass(&ppc));
+
+ mir_snwprintf(desc, L"%s/%s", m_tszUserName, TranslateT("Calls"));
+ mir_snprintf(name, "%s_%s", m_szModuleName, "Call");
+ ppc.hIcon = g_plugin.getIcon(IDI_CALL);
+ ppc.iSeconds = 30;
+ ppc.PluginWindowProc = PopupDlgProcCall;
+ m_PopupClasses.insert(Popup_RegisterClass(&ppc));
+}
+
+void CTeamsProto::UninitPopups()
+{
+ for (auto &it : m_PopupClasses)
+ Popup_UnregisterClass(it);
+}
+
+void CTeamsProto::ShowNotification(const wchar_t *caption, const wchar_t *message, MCONTACT hContact, int type)
+{
+ if (Miranda_IsTerminated())
+ return;
+
+ CMStringA className(FORMAT, "%s_", m_szModuleName);
+
+ switch (type) {
+ case 1:
+ className.Append("Error");
+ break;
+
+ default:
+ className.Append("Notification");
+ break;
+ }
+
+ POPUPDATACLASS ppd = {};
+ ppd.szTitle.w = caption;
+ ppd.szText.w = message;
+ ppd.pszClassName = className.GetBuffer();
+ ppd.hContact = hContact;
+ Popup_AddClass(&ppd);
+}
+
+void CTeamsProto::ShowNotification(const wchar_t *message, MCONTACT hContact)
+{
+ ShowNotification(_T(MODULENAME), message, hContact);
+}
+
+LRESULT CTeamsProto::PopupDlgProcCall(HWND hPopup, UINT uMsg, WPARAM wParam, LPARAM lParam)
+{
+ switch (uMsg) {
+ case WM_CONTEXTMENU:
+ PUDeletePopup(hPopup);
+ CallService(MODULENAME "/IncomingCallPP", 0, PUGetContact(hPopup));
+ break;
+ case WM_COMMAND:
+ PUDeletePopup(hPopup);
+ CallService(MODULENAME"/IncomingCallPP", 1, PUGetContact(hPopup));
+ break;
+ }
+
+ return DefWindowProc(hPopup, uMsg, wParam, lParam);
+}
diff --git a/protocols/Teams/src/teams_profile.cpp b/protocols/Teams/src/teams_profile.cpp
new file mode 100644
index 0000000000..2402467c51
--- /dev/null
+++ b/protocols/Teams/src/teams_profile.cpp
@@ -0,0 +1,153 @@
+/*
+Copyright (c) 2025 Miranda NG team (https://miranda-ng.org)
+
+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 version 2
+of the License.
+
+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 CTeamsProto::UpdateProfileDisplayName(const JSONNode &root, MCONTACT hContact)
+{
+ ptrW firstname(getWStringA(hContact, "FirstName"));
+ ptrW lastname(getWStringA(hContact, "LastName"));
+ if (firstname) {
+ CMStringW nick(firstname);
+ if (lastname)
+ nick.AppendFormat(L" %s", lastname.get());
+ setWString(hContact, "Nick", nick);
+ }
+ else if (lastname)
+ setWString(hContact, "Nick", lastname);
+ else {
+ const JSONNode &node = root["displayname"];
+ SetString(hContact, "Nick", (node) ? node : root["username"]);
+ }
+}
+
+void CTeamsProto::UpdateProfileGender(const JSONNode &root, MCONTACT hContact)
+{
+ CMStringW gender = root["gender"].as_mstring();
+ if (!gender.IsEmpty() && gender != "null")
+ setByte(hContact, "Gender", (uint8_t)(_wtoi(gender) == 1 ? 'M' : 'F'));
+ else
+ delSetting(hContact, "Gender");
+}
+
+void CTeamsProto::UpdateProfileBirthday(const JSONNode &root, MCONTACT hContact)
+{
+ CMStringW birthday = root["birthday"].as_mstring();
+ if (!birthday.IsEmpty() && birthday != "null") {
+ int d, m, y;
+ if (3 == swscanf(birthday.GetBuffer(), L"%d-%d-%d", &y, &m, &d)) {
+ Contact::SetBirthday(hContact, d, m, y);
+ return;
+ }
+ }
+
+ delSetting(hContact, "BirthYear");
+ delSetting(hContact, "BirthDay");
+ delSetting(hContact, "BirthMonth");
+}
+
+void CTeamsProto::UpdateProfileCountry(const JSONNode &root, MCONTACT hContact)
+{
+ std::string isocode = root["country"].as_string();
+ if (!isocode.empty() && isocode != "null") {
+ char *country = (char *)CallService(MS_UTILS_GETCOUNTRYBYISOCODE, (WPARAM)isocode.c_str(), 0);
+ setString(hContact, "Country", country);
+ }
+ else delSetting(hContact, "Country");
+}
+
+void CTeamsProto::UpdateProfileEmails(const JSONNode &root, MCONTACT hContact)
+{
+ const JSONNode &node = root["emails"];
+ if (node) {
+ const JSONNode &items = node.as_array();
+ for (int i = 0; i < min(items.size(), 3); i++) {
+ const JSONNode &item = items.at(i);
+ if (!item)
+ break;
+
+ CMStringA name(FORMAT, "e-mail%d", i);
+ setWString(hContact, name, item.as_mstring());
+ }
+ }
+ else {
+ delSetting(hContact, "e-mail0");
+ delSetting(hContact, "e-mail1");
+ delSetting(hContact, "e-mail2");
+ }
+}
+
+void CTeamsProto::UpdateProfileAvatar(const JSONNode &root, MCONTACT hContact)
+{
+ CMStringW szUrl = root["avatarUrl"].as_mstring();
+ if (!szUrl.IsEmpty() && szUrl != "null") {
+ SetAvatarUrl(hContact, szUrl);
+ ReloadAvatarInfo(hContact);
+ }
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+void CTeamsProto::OnGetProfileInfo(MHttpResponse *response, AsyncHttpRequest *pRequest)
+{
+ MCONTACT hContact = (DWORD_PTR)pRequest->pUserInfo;
+
+ TeamsReply reply(response);
+ if (reply.error()) {
+ ProtoBroadcastAck(hContact, ACKTYPE_GETINFO, ACKRESULT_FAILED, 0);
+ return;
+ }
+
+ auto &root = reply.data();
+ std::string username = root["username"].as_string();
+ if (username.empty()) {
+ ProtoBroadcastAck(hContact, ACKTYPE_GETINFO, ACKRESULT_FAILED, 0);
+ return;
+ }
+
+ if (m_szSkypename != username.c_str())
+ m_szMyname = username.c_str();
+ else
+ m_szMyname = m_szSkypename;
+
+ SetString(hContact, "City", root["city"]);
+ SetString(hContact, "About", root["about"]);
+ SetString(hContact, "Phone", root["phone"]);
+ SetString(hContact, "State", root["province"]);
+ SetString(hContact, "Cellular", root["phoneMobile"]);
+ SetString(hContact, "Homepage", root["homepage"]);
+ SetString(hContact, "LastName", root["lastname"]);
+ SetString(hContact, "FirstName", root["firstname"]);
+ SetString(hContact, "CompanyPhone", root["phoneOffice"]);
+
+ UpdateProfileDisplayName(root, hContact);
+ UpdateProfileGender(root, hContact);
+ UpdateProfileBirthday(root, hContact);
+ UpdateProfileCountry(root, hContact);
+ UpdateProfileEmails(root, hContact);
+ UpdateProfileAvatar(root, hContact);
+
+ ProtoBroadcastAck(hContact, ACKTYPE_GETINFO, ACKRESULT_SUCCESS, 0);
+}
+
+void CTeamsProto::GetProfileInfo(MCONTACT hContact)
+{
+ auto *pReq = new AsyncHttpRequest(REQUEST_GET, HOST_API, 0, &CTeamsProto::OnGetProfileInfo);
+ pReq->m_szUrl.AppendFormat("/users/%s/profile", (hContact == 0) ? "self" : mir_urlEncode(getId(hContact)));
+ pReq->pUserInfo = (void *)hContact;
+ PushRequest(pReq);
+}
diff --git a/protocols/Teams/src/teams_proto.cpp b/protocols/Teams/src/teams_proto.cpp
new file mode 100644
index 0000000000..dfa09503e0
--- /dev/null
+++ b/protocols/Teams/src/teams_proto.cpp
@@ -0,0 +1,297 @@
+/*
+Copyright (c) 2025 Miranda NG team (https://miranda-ng.org)
+
+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 version 2
+of the License.
+
+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"
+
+CTeamsProto::CTeamsProto(const char *protoName, const wchar_t *userName) :
+ PROTO<CTeamsProto>(protoName, userName),
+ m_impl(*this),
+ m_requests(10),
+ m_PopupClasses(1),
+ m_OutMessages(3, PtrKeySortT),
+ m_bAutoHistorySync(this, "AutoSync", true),
+ m_bUseHostnameAsPlace(this, "UseHostName", true),
+ m_bUseBBCodes(this, "UseBBCodes", true),
+ m_wstrCListGroup(this, DBKEY_GROUP, L"Teams"),
+ m_wstrPlace(this, "Place", L""),
+ m_iMood(this, "Mood", 0),
+ m_wstrMoodEmoji(this, "MoodEmoji", L""),
+ m_wstrMoodMessage(this, "XStatusMsg", L"")
+{
+ // create endpoint
+ m_szEndpoint = getMStringA("Endpoint");
+ if (m_szEndpoint.IsEmpty()) {
+ m_szEndpoint = Utils_GenerateUUID();
+ setString("Endpoint", m_szEndpoint);
+ }
+
+ // network
+ NETLIBUSER nlu = {};
+ nlu.flags = NUF_OUTGOING | NUF_INCOMING | NUF_HTTPCONNS | NUF_UNICODE;
+ nlu.szDescriptiveName.w = m_tszUserName;
+ nlu.szSettingsModule = m_szModuleName;
+ m_hNetlibUser = Netlib_RegisterUser(&nlu);
+
+ CMStringA module(FORMAT, "%s.TRouter", m_szModuleName);
+ CMStringW descr(FORMAT, TranslateT("%s websocket connection"), m_tszUserName);
+ nlu.szSettingsModule = module.GetBuffer();
+ nlu.flags = NUF_INCOMING | NUF_OUTGOING | NUF_UNICODE;
+ nlu.szDescriptiveName.w = descr.GetBuffer();
+ m_hTrouterNetlibUser = Netlib_RegisterUser(&nlu);
+
+ CreateProtoService(PS_GETAVATARINFO, &CTeamsProto::SvcGetAvatarInfo);
+ CreateProtoService(PS_GETAVATARCAPS, &CTeamsProto::SvcGetAvatarCaps);
+ CreateProtoService(PS_GETMYAVATAR, &CTeamsProto::SvcGetMyAvatar);
+ CreateProtoService(PS_SETMYAVATAR, &CTeamsProto::SvcSetMyAvatar);
+
+ CreateProtoService(PS_OFFLINEFILE, &CTeamsProto::SvcOfflineFile);
+
+ CreateProtoService(PS_MENU_REQAUTH, &CTeamsProto::OnRequestAuth);
+ CreateProtoService(PS_MENU_GRANTAUTH, &CTeamsProto::OnGrantAuth);
+
+ CreateProtoService(PS_MENU_LOADHISTORY, &CTeamsProto::SvcLoadHistory);
+ CreateProtoService(PS_EMPTY_SRV_HISTORY, &CTeamsProto::SvcEmptyHistory);
+
+ HookProtoEvent(ME_OPT_INITIALISE, &CTeamsProto::OnOptionsInit);
+
+ CreateDirectoryTreeW(GetAvatarPath());
+
+ // sounds
+ g_plugin.addSound("skype_inc_call", L"SkypeWeb", LPGENW("Incoming call"));
+ g_plugin.addSound("skype_call_canceled", L"SkypeWeb", LPGENW("Incoming call canceled"));
+
+ InitGroupChatModule();
+}
+
+CTeamsProto::~CTeamsProto()
+{
+ UninitPopups();
+}
+
+void CTeamsProto::OnEventDeleted(MCONTACT hContact, MEVENT hDbEvent, int flags)
+{
+ if (!hContact || !(flags & CDF_DEL_HISTORY))
+ return;
+
+ DB::EventInfo dbei(hDbEvent, false);
+ if (dbei.szId) {
+ auto *pReq = new AsyncHttpRequest(REQUEST_DELETE, HOST_CHATS, "/users/ME/conversations/" + mir_urlEncode(getId(hContact)) + "/messages/" + dbei.szId);
+ pReq->AddAuthentication(this);
+ pReq->AddHeader("Origin", "https://web.skype.com");
+ pReq->AddHeader("Referer", "https://web.skype.com/");
+ PushRequest(pReq);
+ }
+}
+
+void CTeamsProto::OnEventEdited(MCONTACT hContact, MEVENT, const DBEVENTINFO &dbei)
+{
+ if (dbei.szId)
+ SendServerMsg(hContact, dbei.pBlob, _atoi64(dbei.szId));
+}
+
+void CTeamsProto::OnModulesLoaded()
+{
+ setAllContactStatuses(ID_STATUS_OFFLINE, false);
+
+ HookProtoEvent(ME_MSG_PRECREATEEVENT, &CTeamsProto::OnPreCreateMessage);
+
+ InitPopups();
+}
+
+void CTeamsProto::OnShutdown()
+{
+ StopQueue();
+ StopTrouter();
+}
+
+INT_PTR CTeamsProto::GetCaps(int type, MCONTACT)
+{
+ switch (type) {
+ case PFLAGNUM_1:
+ return PF1_IM | PF1_AUTHREQ | PF1_CHAT | PF1_BASICSEARCH | PF1_MODEMSG | PF1_FILE | PF1_SERVERCLIST;
+ case PFLAGNUM_2:
+ case PFLAGNUM_3:
+ return PF2_ONLINE | PF2_SHORTAWAY | PF2_LONGAWAY | PF2_LIGHTDND | PF2_HEAVYDND;
+ case PFLAGNUM_4:
+ return PF4_NOAUTHDENYREASON | PF4_SUPPORTTYPING | PF4_AVATARS | PF4_IMSENDOFFLINE | PF4_OFFLINEFILES | PF4_SERVERMSGID | PF4_SERVERFORMATTING;
+ case PFLAG_UNIQUEIDTEXT:
+ return (INT_PTR)TranslateT("Teams ID");
+ }
+ return 0;
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+MCONTACT CTeamsProto::AddToList(int, PROTOSEARCHRESULT *psr)
+{
+ debugLogA(__FUNCTION__);
+
+ if (psr->id.a == nullptr)
+ return NULL;
+
+ MCONTACT hContact;
+ if (psr->flags & PSR_UNICODE)
+ hContact = AddContact(T2Utf(psr->id.w), T2Utf(psr->nick.w));
+ else
+ hContact = AddContact(psr->id.a, psr->nick.a);
+
+ return hContact;
+}
+
+MCONTACT CTeamsProto::AddToListByEvent(int, int, MEVENT hDbEvent)
+{
+ debugLogA(__FUNCTION__);
+
+ DB::EventInfo dbei(hDbEvent);
+ if (!dbei)
+ return NULL;
+ if (mir_strcmp(dbei.szModule, m_szModuleName))
+ return NULL;
+ if (dbei.eventType != EVENTTYPE_AUTHREQUEST)
+ return NULL;
+
+ DB::AUTH_BLOB blob(dbei.pBlob);
+ return AddContact(blob.get_email(), blob.get_nick());
+}
+
+int CTeamsProto::Authorize(MEVENT hDbEvent)
+{
+ MCONTACT hContact = GetContactFromAuthEvent(hDbEvent);
+ if (hContact == INVALID_CONTACT_ID)
+ return 1;
+
+ PushRequest(new AsyncHttpRequest(REQUEST_POST, HOST_CONTACTS, "/users/SELF/invites/" + mir_urlEncode(getId(hContact)) + "/accept"));
+ return 0;
+}
+
+int CTeamsProto::AuthDeny(MEVENT hDbEvent, const wchar_t *)
+{
+ MCONTACT hContact = GetContactFromAuthEvent(hDbEvent);
+ if (hContact == INVALID_CONTACT_ID)
+ return 1;
+
+ PushRequest(new AsyncHttpRequest(REQUEST_POST, HOST_CONTACTS, "/users/SELF/invites/" + mir_urlEncode(getId(hContact)) + "/decline"));
+ return 0;
+}
+
+int CTeamsProto::AuthRecv(MCONTACT, DB::EventInfo &dbei)
+{
+ return Proto_AuthRecv(m_szModuleName, dbei);
+}
+
+int CTeamsProto::AuthRequest(MCONTACT hContact, const wchar_t *szMessage)
+{
+ if (hContact == INVALID_CONTACT_ID)
+ return 1;
+
+ auto *pReq = new AsyncHttpRequest(REQUEST_PUT, HOST_CONTACTS, "/users/SELF/contacts");
+
+ JSONNode node;
+ node << CHAR_PARAM("mri", getId(hContact));
+ if (mir_wstrlen(szMessage))
+ node << WCHAR_PARAM("greeting", szMessage);
+ pReq->m_szParam = node.write().c_str();
+
+ PushRequest(pReq);
+ return 0;
+}
+
+int CTeamsProto::GetInfo(MCONTACT hContact, int)
+{
+ if (isChatRoom(hContact))
+ return 1;
+
+ GetProfileInfo(hContact);
+ return 0;
+}
+
+int CTeamsProto::SendMsg(MCONTACT hContact, MEVENT, const char *szMessage)
+{
+ return SendServerMsg(hContact, szMessage);
+}
+
+int CTeamsProto::SetStatus(int iNewStatus)
+{
+ if (iNewStatus == m_iDesiredStatus)
+ return 0;
+
+ debugLogA(__FUNCTION__ ": changing status from %i to %i", m_iStatus, iNewStatus);
+
+ int old_status = m_iStatus;
+ m_iDesiredStatus = iNewStatus;
+
+ if (iNewStatus == ID_STATUS_OFFLINE) {
+ m_iStatus = m_iDesiredStatus = ID_STATUS_OFFLINE;
+ StopQueue();
+ StopTrouter();
+
+ ProtoBroadcastAck(NULL, ACKTYPE_STATUS, ACKRESULT_SUCCESS, (HANDLE)old_status, ID_STATUS_OFFLINE);
+
+ if (!Miranda_IsTerminated())
+ setAllContactStatuses(ID_STATUS_OFFLINE, false);
+ return 0;
+ }
+
+ if (m_iStatus == ID_STATUS_OFFLINE)
+ Login();
+ else
+ SetServerStatus(m_iDesiredStatus);
+
+ return 0;
+}
+
+int CTeamsProto::UserIsTyping(MCONTACT hContact, int iState)
+{
+ JSONNode node;
+ node << INT64_PARAM("clientmessageid", getRandomId()) << CHAR_PARAM("contenttype", "Application/Message") << CHAR_PARAM("content", "")
+ << CHAR_PARAM("messagetype", (iState == PROTOTYPE_SELFTYPING_ON) ? "Control/Typing" : "Control/ClearTyping");
+
+ auto *pReq = new AsyncHttpRequest(REQUEST_POST, HOST_CHATS, "/users/ME/conversations/" + mir_urlEncode(getId(hContact)) + "/messages");
+ pReq->m_szParam = node.write().c_str();
+ PushRequest(pReq);
+ return 0;
+}
+
+int CTeamsProto::RecvContacts(MCONTACT hContact, DB::EventInfo &dbei)
+{
+ PROTOSEARCHRESULT **isrList = (PROTOSEARCHRESULT **)dbei.pBlob;
+
+ int nCount = dbei.cbBlob;
+
+ uint32_t cbBlob = 0;
+ for (int i = 0; i < nCount; i++)
+ cbBlob += int(/*mir_wstrlen(isrList[i]->nick.w)*/0 + 2 + mir_wstrlen(isrList[i]->id.w));
+
+ char *pBlob = (char *)mir_calloc(cbBlob);
+ char *pCurBlob = pBlob;
+
+ for (int i = 0; i < nCount; i++) {
+ pCurBlob += mir_strlen(pCurBlob) + 1;
+
+ mir_strcpy(pCurBlob, _T2A(isrList[i]->id.w));
+ pCurBlob += mir_strlen(pCurBlob) + 1;
+ }
+
+ dbei.szModule = m_szModuleName;
+ dbei.eventType = EVENTTYPE_CONTACTS;
+ dbei.cbBlob = cbBlob;
+ dbei.pBlob = pBlob;
+ db_event_add(hContact, &dbei);
+
+ mir_free(pBlob);
+ return 0;
+}
diff --git a/protocols/Teams/src/teams_proto.h b/protocols/Teams/src/teams_proto.h
new file mode 100644
index 0000000000..921a8e1ef1
--- /dev/null
+++ b/protocols/Teams/src/teams_proto.h
@@ -0,0 +1,386 @@
+#define TEAMS_CLIENT_ID "8ec6bc83-69c8-4392-8f08-b3c986009232"
+#define TEAMS_CLIENTINFO_NAME "skypeteams"
+#define TEAMS_CLIENTINFO_VERSION "49/24062722442"
+
+#define TEAMS_BASE_HOST "teams.live.com"
+
+#define TEAMS_USER_AGENT "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0 Teams/24165.1410.2974.6689/49"
+
+#define DBKEY_ID "id"
+#define DBKEY_GROUP "DefaultGroup"
+#define DBKEY_RTOKEN "RefreshToken"
+
+struct COwnMessage
+{
+ COwnMessage(int _1, int64_t _2) :
+ hMessage(_1),
+ hClientMessageId(_2)
+ {}
+
+ COwnMessage(const char *pszText) :
+ hMessage(0),
+ hClientMessageId(0),
+ szMessage(mir_strdup(pszText))
+ {}
+
+ int hMessage;
+ int64_t hClientMessageId, iTimestamp = -1;
+ ptrA szMessage;
+};
+
+struct CSkypeTransfer
+{
+ CMStringA docId, fileName, fileType, url;
+ int iFileSize = 0, iWidth = -1, iHeight = -1;
+};
+
+class CTeamsProto : public PROTO<CTeamsProto>
+{
+ friend class COptionsMainDlg;
+ friend class CDeviceCodeDlg;
+
+ friend class CSkypeOptionsMain;
+ friend class CSkypeGCCreateDlg;
+ friend class CSkypeInviteDlg;
+ friend class CMoodDialog;
+ friend class CDeviceCodeDlg;
+
+ class CTeamsProtoImpl
+ {
+ friend class CTeamsProto;
+ CTeamsProto &m_proto;
+
+ CTimer m_heartBeat, m_loginPoll;
+ void OnHeartBeat(CTimer *)
+ {
+ m_proto.TRouterSendJson("ping");
+ }
+ void OnLoginPoll(CTimer *)
+ {
+ m_proto.LoginPoll();
+ }
+
+ CTeamsProtoImpl(CTeamsProto &pro) :
+ m_proto(pro),
+ m_heartBeat(Miranda_GetSystemWindow(), UINT_PTR(this) + 1),
+ m_loginPoll(Miranda_GetSystemWindow(), UINT_PTR(this) + 2)
+ {
+ m_heartBeat.OnEvent = Callback(this, &CTeamsProtoImpl::OnHeartBeat);
+ m_loginPoll.OnEvent = Callback(this, &CTeamsProtoImpl::OnLoginPoll);
+ }
+ } m_impl;
+
+public:
+ // constructor
+ CTeamsProto(const char *protoName, const wchar_t *userName);
+ ~CTeamsProto();
+
+ //////////////////////////////////////////////////////////////////////////////////////
+ // Virtual functions
+
+ MCONTACT AddToList(int flags, PROTOSEARCHRESULT* psr) override;
+ MCONTACT AddToListByEvent(int flags, int iContact, MEVENT hDbEvent) override;
+ int AuthRequest(MCONTACT hContact, const wchar_t* szMessage) override;
+ int Authorize(MEVENT hDbEvent) override;
+ int AuthDeny(MEVENT hDbEvent, const wchar_t* szReason) override;
+ int AuthRecv(MCONTACT hContact, DB::EventInfo &dbei) override;
+ INT_PTR GetCaps(int type, MCONTACT hContact = NULL) override;
+ int GetInfo(MCONTACT hContact, int infoType) override;
+ HANDLE SearchBasic(const wchar_t* id) override;
+ int SendMsg(MCONTACT hContact, MEVENT hReplyEvent, const char* msg) override;
+ int SetStatus(int iNewStatus) override;
+ int UserIsTyping(MCONTACT hContact, int type) override;
+ int RecvContacts(MCONTACT hContact, DB::EventInfo &dbei) override;
+ HANDLE SendFile(MCONTACT hContact, const wchar_t *szDescription, wchar_t **ppszFiles) override;
+
+ void OnBuildProtoMenu(void) override;
+ bool OnContactDeleted(MCONTACT, uint32_t flags) override;
+ MWindow OnCreateAccMgrUI(MWindow) override;
+ void OnEventEdited(MCONTACT hContact, MEVENT hDbEvent, const DBEVENTINFO &dbei) override;
+ void OnEventDeleted(MCONTACT hContact, MEVENT hDbEvent, int flags) override;
+ void OnMarkRead(MCONTACT, MEVENT) override;
+ void OnModulesLoaded() override;
+ void OnReceiveOfflineFile(DB::EventInfo &dbei, DB::FILE_BLOB &blob) override;
+ void OnShutdown() override;
+
+ // menus
+ static void InitMenus();
+
+ // popups
+ void InitPopups();
+ void UninitPopups();
+
+ // search
+
+ //////////////////////////////////////////////////////////////////////////////////////
+ // settings
+
+ CMOption<bool> m_bAutoHistorySync;
+ CMOption<bool> m_bUseBBCodes;
+
+ CMOption<bool> m_bUseHostnameAsPlace;
+ CMOption<wchar_t*> m_wstrPlace;
+
+ CMOption<wchar_t*> m_wstrCListGroup;
+
+ CMOption<uint8_t> m_iMood;
+ CMOption<wchar_t*> m_wstrMoodMessage, m_wstrMoodEmoji;
+
+ //////////////////////////////////////////////////////////////////////////////////////
+ // other data
+
+ int m_iPollingId, m_iMessageId = 1;
+ CMStringA m_szSkypename, m_szMyname, m_szOwnSkypeId, m_szSkypeToken, m_szEndpoint, m_szApiCookie;
+ MCONTACT m_hMyContact;
+
+ __forceinline CMStringA getId(MCONTACT hContact) {
+ return getMStringA(hContact, DBKEY_ID);
+ }
+
+private:
+ bool m_bHistorySynced;
+
+ static std::map<std::wstring, std::wstring> languages;
+
+ LIST<void> m_PopupClasses;
+
+ // avatars
+ bool ReceiveAvatar(MCONTACT hContact);
+ void OnReceiveAvatar(MHttpResponse *response, AsyncHttpRequest *pRequest);
+
+ void SetAvatarUrl(MCONTACT hContact, const CMStringW &tszUrl);
+
+ void ReloadAvatarInfo(MCONTACT hContact);
+ void GetAvatarFileName(MCONTACT hContact, wchar_t *pszDest, size_t cbLen);
+
+ INT_PTR __cdecl SvcGetAvatarInfo(WPARAM, LPARAM);
+ INT_PTR __cdecl SvcGetAvatarCaps(WPARAM, LPARAM);
+ INT_PTR __cdecl SvcGetMyAvatar(WPARAM, LPARAM);
+
+ void OnSentAvatar(MHttpResponse *response, AsyncHttpRequest *pRequest);
+ INT_PTR __cdecl SvcSetMyAvatar(WPARAM, LPARAM);
+
+ // chats
+ void InitGroupChatModule();
+
+ int __cdecl OnGroupChatEventHook(WPARAM, LPARAM lParam);
+ int __cdecl OnGroupChatMenuHook(WPARAM, LPARAM lParam);
+ INT_PTR __cdecl OnJoinChatRoom(WPARAM hContact, LPARAM);
+ INT_PTR __cdecl OnLeaveChatRoom(WPARAM hContact, LPARAM);
+
+ void OnGetChatInfo(MHttpResponse *response, AsyncHttpRequest *pRequest);
+ void GetChatInfo(const wchar_t *chatId);
+
+ void OnGetChatMembers(MHttpResponse *response, AsyncHttpRequest *pRequest);
+ void GetChatMembers(const LIST<char> &ids, SESSION_INFO *si);
+
+ SESSION_INFO *StartChatRoom(const wchar_t *tid, const wchar_t *tname, const char *pszVersion = nullptr);
+
+ bool OnChatEvent(const JSONNode &node);
+ wchar_t *GetChatContactNick(SESSION_INFO *si, const wchar_t *id, const wchar_t *name = nullptr, bool *isQualified = nullptr);
+
+ bool AddChatContact(SESSION_INFO *si, const wchar_t *id, const wchar_t *role, bool isChange = false);
+ void RemoveChatContact(SESSION_INFO *si, const wchar_t *id, const wchar_t *initiator = L"");
+ void SendChatMessage(SESSION_INFO *si, const wchar_t *tszMessage);
+
+ void InviteUserToChat(const char *chatId, const char *skypename, const char *role);
+ void KickChatUser(const char *chatId, const char *userId);
+ void SetChatProperty(const char *chatId, const char *propname, const char *value);
+ void SetChatStatus(MCONTACT hContact, int iStatus);
+
+ bool ParseMessage(const JSONNode &node, DB::EventInfo &dbei);
+
+ // contacts
+ void LoadContactsAuth(MHttpResponse *response, AsyncHttpRequest *pRequest);
+ void OnGotContactsInfo(MHttpResponse *response, AsyncHttpRequest *pRequest);
+
+ void GetShortInfo(const OBJLIST<char> &ids);
+ void RefreshContactsInfo();
+ void SetContactStatus(MCONTACT hContact, uint16_t status);
+
+ MCONTACT FindContact(const char *skypeId);
+ MCONTACT FindContact(const wchar_t *skypeId);
+
+ MCONTACT AddContact(const char *skypename, const char *nick, bool isTemporary = false);
+
+ MCONTACT GetContactFromAuthEvent(MEVENT hEvent);
+
+ INT_PTR __cdecl BlockContact(WPARAM hContact, LPARAM);
+ void OnBlockContact(MHttpResponse *response, AsyncHttpRequest *pRequest);
+
+ INT_PTR __cdecl UnblockContact(WPARAM hContact, LPARAM);
+ void OnUnblockContact(MHttpResponse *response, AsyncHttpRequest *pRequest);
+
+ // files
+ void SendFile(CFileUploadParam *fup);
+ void OnASMObjectCreated(MHttpResponse *response, AsyncHttpRequest *pRequest);
+ void OnASMObjectUploaded(MHttpResponse *response, AsyncHttpRequest *pRequest);
+
+ void __cdecl ReceiveFileThread(void *param);
+
+ INT_PTR __cdecl SvcOfflineFile(WPARAM, LPARAM);
+
+ // history
+ void FetchMissingHistory(const JSONNode &node, MCONTACT);
+ void GetServerHistory(MCONTACT hContact, int pageSize, int64_t timestamp, bool bOperative);
+ void RefreshConversations();
+
+ void OnGetServerHistory(MHttpResponse *response, AsyncHttpRequest *pRequest);
+ void OnSyncConversations(MHttpResponse *response, AsyncHttpRequest *pRequest);
+
+ // http queue
+ bool m_isTerminated = true;
+ mir_cs m_requestQueueLock;
+ LIST<AsyncHttpRequest> m_requests;
+ MEventHandle m_hRequestQueueEvent;
+ HANDLE m_hRequestQueueThread;
+ CMStringA m_szAccessToken, m_szSubstrateToken;
+
+ void __cdecl WorkerThread(void *);
+
+ void StartQueue();
+ void StopQueue();
+
+ MHttpResponse *DoSend(AsyncHttpRequest *request);
+
+ void Execute(AsyncHttpRequest *request);
+ void PushRequest(AsyncHttpRequest *request);
+
+ // login
+ CMStringW m_wszUserCode;
+ CMStringA m_szDeviceCode, m_szDeviceCookie, m_szVerificationUrl;
+ time_t m_iLoginExpires;
+
+ void Login();
+ void LoggedIn();
+ void LoginPoll();
+ void LoginError();
+
+ void OnEndpointCreated(MHttpResponse *response, AsyncHttpRequest *pRequest);
+ void OnReceiveApiCookie(MHttpResponse *response, AsyncHttpRequest *pRequest);
+
+ void OauthRefreshServices();
+ void RefreshToken(const char *pszScope, AsyncHttpRequest::MTHttpRequestHandler pFunc);
+
+ void OnReceiveSkypeToken(MHttpResponse *response, AsyncHttpRequest *pRequest);
+ void OnReceiveDevicePoll(MHttpResponse *response, AsyncHttpRequest *pRequest);
+ void OnReceiveDeviceToken(MHttpResponse *response, AsyncHttpRequest *pRequest);
+ void OnRefreshAccessToken(MHttpResponse *response, AsyncHttpRequest *pRequest);
+ void OnRefreshSkypeToken(MHttpResponse *response, AsyncHttpRequest *pRequest);
+ void OnRefreshSubstrate(MHttpResponse *response, AsyncHttpRequest *pRequest);
+
+ // menus
+ static HGENMENU ContactMenuItems[CMI_MAX];
+ int OnPrebuildContactMenu(WPARAM hContact, LPARAM);
+ static int PrebuildContactMenu(WPARAM hContact, LPARAM lParam);
+
+ // messages
+ mir_cs m_lckOutMessagesList;
+ LIST<COwnMessage> m_OutMessages;
+
+ void OnMessageSent(MHttpResponse *response, AsyncHttpRequest *pRequest);
+ int SendServerMsg(MCONTACT hContact, const char *szMessage, int64_t iMessageId = 0);
+
+ int __cdecl OnPreCreateMessage(WPARAM, LPARAM lParam);
+
+ void ProcessContactRecv(MCONTACT hContact, const char *szContent, DB::EventInfo &dbei);
+ void ProcessFileRecv(MCONTACT hContact, const char *szContent, DB::EventInfo &dbei);
+
+ // options
+ int __cdecl OnOptionsInit(WPARAM wParam, LPARAM lParam);
+
+ // profile
+ void OnGetProfileInfo(MHttpResponse *response, AsyncHttpRequest *pRequest);
+ void GetProfileInfo(MCONTACT hContact);
+
+ void UpdateProfileDisplayName(const JSONNode &root, MCONTACT hContact = NULL);
+ void UpdateProfileGender(const JSONNode &root, MCONTACT hContact = NULL);
+ void UpdateProfileBirthday(const JSONNode &root, MCONTACT hContact = NULL);
+ void UpdateProfileCountry(const JSONNode &node, MCONTACT hContact = NULL);
+ void UpdateProfileEmails(const JSONNode &root, MCONTACT hContact = NULL);
+ void UpdateProfileAvatar(const JSONNode &root, MCONTACT hContact = NULL);
+
+ // search
+ void OnSearch(MHttpResponse *response, AsyncHttpRequest *pRequest);
+
+ // server requests
+ void OnStatusChanged(MHttpResponse *response, AsyncHttpRequest *pRequest);
+ void SetServerStatus(int iStatus);
+
+ void CreateContactSubscription();
+
+ // utils
+ __forceinline bool IsOnline() const
+ { return (m_iStatus > ID_STATUS_OFFLINE);
+ }
+
+ bool IsMe(const wchar_t *str);
+ bool IsMe(const char *str);
+
+ int64_t getLastTime(MCONTACT);
+ void setLastTime(MCONTACT, int64_t);
+
+ CMStringW RemoveHtml(const CMStringW &src);
+
+ void ShowNotification(const wchar_t *message, MCONTACT hContact = NULL);
+ void ShowNotification(const wchar_t *caption, const wchar_t *message, MCONTACT hContact = NULL, int type = 0);
+ static bool IsFileExists(std::wstring path);
+
+ static LRESULT CALLBACK PopupDlgProcCall(HWND hPopup, UINT uMsg, WPARAM wParam, LPARAM lParam);
+
+ void SetString(MCONTACT hContact, const char *pszSetting, const JSONNode &node);
+
+ CMStringW ChangeTopicForm();
+
+ // services
+ INT_PTR __cdecl OnRequestAuth(WPARAM hContact, LPARAM);
+ INT_PTR __cdecl OnGrantAuth(WPARAM hContact, LPARAM);
+ INT_PTR __cdecl SvcLoadHistory(WPARAM hContact, LPARAM);
+ INT_PTR __cdecl SvcEmptyHistory(WPARAM hContact, LPARAM);
+ INT_PTR __cdecl SvcCreateChat(WPARAM, LPARAM);
+ INT_PTR __cdecl ParseSkypeUriService(WPARAM, LPARAM lParam);
+
+ // trouter
+public:
+ void TRouterProcess(const char *str);
+
+private:
+ HNETLIBUSER m_hTrouterNetlibUser;
+ CMStringA m_szTrouterUrl, m_szTrouterSurl;
+ WebSocket<CTeamsProto> *m_ws;
+ MHttpHeaders m_connectParams;
+ int iCommandId;
+
+ void ProcessEvent(const JSONNode &node);
+ void ProcessNewMessage(const JSONNode &node);
+ void ProcessUserPresence(const JSONNode &node);
+ void ProcessThreadUpdate(const JSONNode &node);
+ void ProcessServerMessage(const std::string &szName, int packetId, const JSONNode &args);
+ void ProcessConversationUpdate(const JSONNode &node);
+
+ void __cdecl GatewayThread(void *);
+ void GatewayThreadWorker();
+
+ void TRouterSendJson(const char *szName, const JSONNode *node = nullptr, int iReplyTo = -1);
+ void TRouterSendJson(const JSONNode &node, int iReplyTo = -1);
+
+ void TRouterSendActive(bool bActive, int iReplyTo = -1);
+ void TRouterRegister();
+ void TRouterRegister(const char *pszAppId, const char *pszKey, const char *pszPath, const char *pszContext);
+
+ void StartTrouter();
+ void StopTrouter();
+
+ void OnTrouterInfo(MHttpResponse *response, AsyncHttpRequest *pRequest);
+ void OnTrouterSession(MHttpResponse *response, AsyncHttpRequest *pRequest);
+};
+
+typedef CProtoDlgBase<CTeamsProto> CTeamsDlgBase;
+
+struct CMPlugin : public ACCPROTOPLUGIN<CTeamsProto>
+{
+ CMPlugin();
+
+ int Load() override;
+ int Unload() override;
+};
diff --git a/protocols/Teams/src/teams_search.cpp b/protocols/Teams/src/teams_search.cpp
new file mode 100644
index 0000000000..c175cbea37
--- /dev/null
+++ b/protocols/Teams/src/teams_search.cpp
@@ -0,0 +1,62 @@
+/*
+Copyright (c) 2025 Miranda NG team (https://miranda-ng.org)
+
+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 version 2
+of the License.
+
+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"
+
+HANDLE CTeamsProto::SearchBasic(const wchar_t *id)
+{
+ debugLogA("CTeamsProto::OnSearchBasicThread");
+ if (!IsOnline())
+ return 0;
+
+ auto *pReq = new AsyncHttpRequest(REQUEST_POST, HOST_TEAMS_API, "/users/searchV2?includeDLs=true&includeBots=true&enableGuest=true&source=newChat&skypeTeamsInfo=true", &CTeamsProto::OnSearch);
+ pReq->m_szParam = mir_urlEncode(T2Utf(id));
+ PushRequest(pReq);
+
+ return (HANDLE)1;
+}
+
+void CTeamsProto::OnSearch(MHttpResponse *response, AsyncHttpRequest*)
+{
+ debugLogA(__FUNCTION__);
+
+ TeamsReply reply(response);
+ if (reply.error()) {
+ ProtoBroadcastAck(0, ACKTYPE_SEARCH, ACKRESULT_SUCCESS, (HANDLE)1, 0);
+ return;
+ }
+
+ auto &root = reply.data();
+ const JSONNode &items = root["results"].as_array();
+ for (auto &it : items) {
+ const JSONNode &item = it["nodeProfileData"];
+
+ std::string skypeId = item["skypeId"].as_string();
+ if (UrlToSkypeId(skypeId.c_str()).IsEmpty())
+ skypeId = "8:" + skypeId;
+
+ std::string name = item["name"].as_string();
+
+ PROTOSEARCHRESULT psr = { sizeof(psr) };
+ psr.flags = PSR_UTF8;
+ psr.id.a = const_cast<char *>(skypeId.c_str());
+ psr.nick.a = const_cast<char *>(name.c_str());
+ ProtoBroadcastAck(0, ACKTYPE_SEARCH, ACKRESULT_DATA, (HANDLE)1, (LPARAM)&psr);
+ }
+
+ ProtoBroadcastAck(0, ACKTYPE_SEARCH, ACKRESULT_SUCCESS, (HANDLE)1, 0);
+}
diff --git a/protocols/Teams/src/teams_server.cpp b/protocols/Teams/src/teams_server.cpp
new file mode 100644
index 0000000000..3345e1f265
--- /dev/null
+++ b/protocols/Teams/src/teams_server.cpp
@@ -0,0 +1,104 @@
+/*
+Copyright (C) 2025 Miranda NG team (https://miranda-ng.org)
+
+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 version 2
+of the License.
+
+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 CTeamsProto::OnReceiveApiCookie(MHttpResponse *response, AsyncHttpRequest *)
+{
+ if (response == nullptr) {
+ ProtoBroadcastAck(NULL, ACKTYPE_LOGIN, ACKRESULT_FAILED, NULL, 1001);
+ SetStatus(ID_STATUS_OFFLINE);
+ return;
+ }
+
+ m_szApiCookie = response->GetCookies();
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+void CTeamsProto::OnStatusChanged(MHttpResponse *response, AsyncHttpRequest *)
+{
+ if (response == nullptr || response->resultCode != 200) {
+ debugLogA(__FUNCTION__ ": failed to change status");
+ ProtoBroadcastAck(NULL, ACKTYPE_LOGIN, ACKRESULT_FAILED, NULL, 1001);
+ SetStatus(ID_STATUS_OFFLINE);
+ return;
+ }
+
+ int oldStatus = m_iStatus;
+ m_iStatus = m_iDesiredStatus;
+ ProtoBroadcastAck(NULL, ACKTYPE_STATUS, ACKRESULT_SUCCESS, (HANDLE)oldStatus, m_iStatus);
+}
+
+void CTeamsProto::SetServerStatus(int iStatus)
+{
+ const char *pszAvailability;
+ switch (iStatus) {
+ case ID_STATUS_OFFLINE:
+ pszAvailability = "Offline";
+ break;
+ case ID_STATUS_NA:
+ case ID_STATUS_AWAY:
+ pszAvailability = "Away";
+ break;
+ case ID_STATUS_DND:
+ pszAvailability = "DoNotDisturb";
+ break;
+ case ID_STATUS_OCCUPIED:
+ pszAvailability = "Busy";
+ break;
+ default:
+ pszAvailability = "Available";
+ }
+
+ JSONNode node(JSON_NODE);
+ node << CHAR_PARAM("availability", pszAvailability);
+
+ auto *pReq = new AsyncHttpRequest(REQUEST_PUT, HOST_PRESENCE, "/me/forceavailability", &CTeamsProto::OnStatusChanged);
+ pReq->m_szParam = node.write().c_str();
+ PushRequest(pReq);
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+void CTeamsProto::CreateContactSubscription()
+{
+ CMStringA szUrl = m_szTrouterSurl;
+ if (szUrl[szUrl.GetLength() - 1] != '/')
+ szUrl += "/";
+ szUrl += "TeamsUnifiedPresenceService";
+
+ JSONNode listAdd(JSON_ARRAY); listAdd.set_name("subscriptionsToAdd");
+ for (auto &hContact : AccContacts())
+ if (!isChatRoom(hContact)) {
+ JSONNode contact;
+ contact << CHAR_PARAM("mri", getId(hContact));
+ listAdd << contact;
+ }
+
+ JSONNode listRemove(JSON_ARRAY); listRemove.set_name("subscriptionsToRemove");
+
+ JSONNode node;
+ node << CHAR_PARAM("trouterUri", szUrl) << BOOL_PARAM("shouldPurgePreviousSubscriptions", true)
+ << listAdd << listRemove;
+
+ auto *pReq = new AsyncHttpRequest(REQUEST_POST, HOST_PRESENCE, "/pubsub/subscriptions/" + m_szEndpoint);
+ pReq->m_szParam = node.write().c_str();
+ PushRequest(pReq);
+}
diff --git a/protocols/Teams/src/teams_trouter.cpp b/protocols/Teams/src/teams_trouter.cpp
new file mode 100644
index 0000000000..0e54b8edb6
--- /dev/null
+++ b/protocols/Teams/src/teams_trouter.cpp
@@ -0,0 +1,342 @@
+/*
+Copyright (C) 2025 Miranda NG team (https://miranda-ng.org)
+
+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 version 2
+of the License.
+
+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"
+
+#define TEAMS_TROUTER_TTL 86400
+#define TEAMS_TROUTER_TCCV "2024.23.01.2"
+
+void CTeamsProto::OnTrouterSession(MHttpResponse *response, AsyncHttpRequest *pRequest)
+{
+ if (!response || response->resultCode != 200) {
+ LoginError();
+ return;
+ }
+
+ int iStart = 0;
+ CMStringA szId = response->body.Tokenize(":", iStart);
+ m_szTrouterUrl = pRequest->m_szUrl;
+ m_szTrouterUrl.Replace("socket.io/1/", "socket.io/1/websocket/" + szId + "/");
+ ForkThread(&CTeamsProto::GatewayThread);
+}
+
+void CTeamsProto::OnTrouterInfo(MHttpResponse *response, AsyncHttpRequest *)
+{
+ TeamsReply reply(response);
+ if (reply.error()) {
+ LoginError();
+ return;
+ }
+
+ auto &root = reply.data();
+ m_szTrouterSurl = root["surl"].as_mstring();
+ CMStringA ccid = root["ccid"].as_mstring();
+ CMStringA szUrl = root["socketio"].as_mstring();
+ szUrl += "socket.io/1/";
+
+ CreateContactSubscription();
+
+ auto *pReq = new AsyncHttpRequest(REQUEST_GET, HOST_OTHER, szUrl, &CTeamsProto::OnTrouterSession);
+ pReq << CHAR_PARAM("v", "v4");
+
+ m_connectParams.destroy();
+ for (auto &it : root["connectparams"]) {
+ m_connectParams.AddHeader(it.name(), it.as_string().c_str());
+ pReq << CHAR_PARAM(it.name(), it.as_string().c_str());
+ }
+
+ pReq << CHAR_PARAM("tc", "{\"cv\":\"" TEAMS_TROUTER_TCCV "\",\"ua\":\"TeamsCDL\",\"hr\":\"\",\"v\":\"" TEAMS_CLIENTINFO_VERSION "\"}")
+ << CHAR_PARAM("con_num", "1234567890123_1") << CHAR_PARAM("epid", m_szEndpoint) << BOOL_PARAM("auth", true) << INT_PARAM("timeout", 40);
+ if (!ccid.IsEmpty())
+ pReq << CHAR_PARAM("ccid", ccid);
+ PushRequest(pReq);
+}
+
+void CTeamsProto::StartTrouter()
+{
+ auto *pReq = new AsyncHttpRequest(REQUEST_POST, HOST_OTHER, "https://go.trouter.skype.com/v4/a", &CTeamsProto::OnTrouterInfo);
+ pReq->m_szUrl.AppendFormat("?epid=%s", m_szEndpoint.c_str());
+ pReq->AddHeader("x-skypetoken", m_szSkypeToken);
+ pReq->flags |= NLHRF_NODUMPHEADERS;
+ PushRequest(pReq);
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+void CTeamsProto::StopTrouter()
+{
+ m_impl.m_heartBeat.StopSafe();
+
+ if (m_ws) {
+ TRouterSendActive(false);
+ m_ws->terminate();
+ m_ws = nullptr;
+ }
+}
+
+void CTeamsProto::GatewayThread(void *)
+{
+ while (!m_isTerminated)
+ GatewayThreadWorker();
+}
+
+void CTeamsProto::GatewayThreadWorker()
+{
+ m_ws = nullptr;
+
+ MHttpHeaders headers;
+ headers.AddHeader("x-skypetoken", m_szSkypeToken);
+ headers.AddHeader("User-Agent", TEAMS_USER_AGENT);
+
+ WebSocket<CTeamsProto> ws(this);
+ NLHR_PTR pReply(ws.connect(m_hTrouterNetlibUser, m_szTrouterUrl, &headers));
+ if (pReply) {
+ if (pReply->resultCode == 101) {
+ m_ws = &ws;
+
+ iCommandId = 1;
+ m_impl.m_heartBeat.StartSafe(30000);
+
+ debugLogA("Websocket connection succeeded");
+ ws.run();
+ }
+ else debugLogA("websocket connection failed: %d", pReply->resultCode);
+ }
+ else debugLogA("websocket connection failed");
+
+ StopTrouter();
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+// TRouter send
+
+void CTeamsProto::TRouterSendJson(const JSONNode &node, int iReplyTo)
+{
+ CMStringA szJson;
+ if (iReplyTo == -1) {
+ iCommandId++;
+ szJson.Format("5:%d+::", iCommandId);
+ }
+ else szJson.Format("5:%d+::", iReplyTo);
+ szJson += node.write().c_str();
+
+ if (m_ws)
+ m_ws->sendText(szJson.c_str());
+}
+
+void CTeamsProto::TRouterSendJson(const char *szName, const JSONNode *node, int iReplyTo)
+{
+ JSONNode payload, args(JSON_ARRAY);
+ payload << CHAR_PARAM("name", szName);
+ if (node) {
+ if (mir_strcmp(node->name(), "args")) {
+ args.set_name("args");
+ args << *node;
+ payload << args;
+ }
+ else payload << *node;
+ }
+
+ CMStringA szJson;
+ if (iReplyTo == -1) {
+ iCommandId++;
+ szJson.Format("5:%d+::", iCommandId);
+ }
+ else szJson.Format("5:%d+::", iReplyTo);
+ szJson += payload.write().c_str();
+
+ if (m_ws)
+ m_ws->sendText(szJson.c_str());
+}
+
+static char szSuffix[4] = { 'A', 'g', 'Q', 'w' };
+
+void CTeamsProto::TRouterSendActive(bool bActive, int iReplyTo)
+{
+ CMStringA cv;
+ srand(time(0));
+ for (int i = 0; i < 21; i++)
+ cv.AppendChar('a' + rand() % 26);
+ cv.AppendChar(szSuffix[rand() % 4]);
+ cv += ".0.1";
+
+ JSONNode payload;
+ payload << CHAR_PARAM("state", bActive ? "active" : "inactive") << CHAR_PARAM("cv", cv);
+ TRouterSendJson("user.activity", &payload, iReplyTo);
+}
+
+void CTeamsProto::TRouterRegister()
+{
+ TRouterRegister("NextGenCalling", "DesktopNgc_2.3:SkypeNgc", m_szTrouterSurl + "NGCallManagerWin", nullptr);
+ TRouterRegister("SkypeSpacesWeb", "SkypeSpacesWeb_2.3", m_szTrouterSurl + "SkypeSpacesWeb", nullptr);
+ TRouterRegister("TeamsCDLWebWorker", "TeamsCDLWebWorker_2.3", m_szTrouterSurl, "");
+ TRouterRegister("TeamsCDLWebWorker", "TeamsCDLWebWorker_2.3", m_szTrouterSurl, "TFL");
+}
+
+void CTeamsProto::TRouterRegister(const char *pszAppId, const char *pszKey, const char *pszPath, const char *pszContext)
+{
+ JSONNode descr, reg, obj, trouter(JSON_ARRAY), transports;
+ descr.set_name("clientDescription");
+ descr << CHAR_PARAM("appId", pszAppId) << CHAR_PARAM("aesKey", "") << CHAR_PARAM("languageId", "en-US")
+ << CHAR_PARAM("platform", "edge") << CHAR_PARAM("templateKey", pszKey) << CHAR_PARAM("platformUIVersion", TEAMS_CLIENTINFO_VERSION);
+ if (pszContext)
+ descr << CHAR_PARAM("productContext", pszContext);
+
+ obj << CHAR_PARAM("context", "") << CHAR_PARAM("path", pszPath) << INT_PARAM("ttl", TEAMS_TROUTER_TTL);
+ trouter.set_name("TROUTER"); trouter << obj;
+ transports.set_name("transports"); transports << trouter;
+
+ reg.set_name("registration");
+ reg << descr << CHAR_PARAM("registrationId", m_szEndpoint) << CHAR_PARAM("nodeId", "") << transports;
+
+ auto *pReq = new AsyncHttpRequest(REQUEST_POST, HOST_OTHER, "https://edge.skype.com/registrar/prod/v2/registrations");
+ pReq->flags |= NLHRF_NODUMPHEADERS;
+ pReq->AddHeader("Content-Type", "application/json");
+ pReq->AddHeader("X-Skypetoken", m_szSkypeToken);
+ pReq->AddHeader("Authorization", "Bearer " + m_szAccessToken);
+ pReq->m_szParam = reg.write().c_str();
+ PushRequest(pReq);
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+// TRouter receive
+
+void WebSocket<CTeamsProto>::process(const uint8_t *buf, size_t cbLen)
+{
+ Netlib_Dump(getConn(), buf, cbLen, false, 0);
+
+ CMStringA payload((const char *)buf, (int)cbLen);
+ p->TRouterProcess(payload);
+}
+
+static const char* skip3colons(const char *str, int *packet_id = nullptr)
+{
+ int nColons = 3;
+ for (const char *p = str; *p; p++) {
+ if (*p == ':') {
+ if (packet_id && nColons == 3)
+ *packet_id = atoi(p+1);
+
+ if (--nColons == 0)
+ return p + 1;
+ }
+ }
+ return str;
+}
+
+void CTeamsProto::TRouterProcess(const char *str)
+{
+ switch (*str) {
+ case '1':
+ TRouterRegister();
+ break;
+
+ case '3':
+ if (auto packet = JSONNode::parse(skip3colons(str))) {
+ std::string szBody(packet["body"].as_string());
+ auto message = JSONNode::parse(szBody.c_str());
+ if (message) {
+ Netlib_Logf(m_hTrouterNetlibUser, "Got event:\n%s", message.write_formatted().c_str());
+ ProcessEvent(message);
+ }
+
+ JSONNode reply, &old = packet["headers"], headers; headers.set_name("headers");
+ headers << WCHAR_PARAM("MS-CV", old["MS-CV"].as_mstring()) << old["trouter-request"] << old["trouter-client"];
+ reply << WCHAR_PARAM("id", packet["id"].as_mstring()) << INT_PARAM("status", 200) << headers << CHAR_PARAM("body", "");
+ if (m_ws)
+ m_ws->sendText(("3:::" + reply.write()).c_str());
+ }
+ break;
+
+ case '5':
+ if (auto root = JSONNode::parse(skip3colons(str, &iCommandId))) {
+ std::string szName(root["name"].as_string());
+ ProcessServerMessage(szName, iCommandId, root["args"]);
+ }
+ break;
+ }
+}
+
+void CTeamsProto::ProcessEvent(const JSONNode &node)
+{
+ if (auto &presence = node["presence"]) {
+ for (auto &it : presence)
+ ProcessUserPresence(it);
+ return;
+ }
+
+ auto szType = node["type"].as_string();
+ if (szType == "EventMessage") {
+ auto &resource = node["resource"];
+ auto szResourceType = node["resourceType"];
+ if (szResourceType == "ConversationUpdate")
+ ProcessConversationUpdate(resource);
+ else if (szResourceType == "NewMessage")
+ ProcessNewMessage(resource);
+ }
+}
+
+void CTeamsProto::ProcessUserPresence(const JSONNode &node)
+{
+ debugLogA(__FUNCTION__);
+
+ CMStringA skypename = node["mri"].as_mstring();
+ auto &presence = node["presence"];
+ std::string status = presence["availability"].as_string();
+
+ if (!skypename.IsEmpty()) {
+ if (IsMe(skypename)) {
+ int iNewStatus = TeamsToMirandaStatus(status.c_str());
+ if (iNewStatus == ID_STATUS_OFFLINE)
+ return;
+
+ int old_status = m_iStatus;
+ m_iDesiredStatus = iNewStatus;
+ m_iStatus = iNewStatus;
+ if (old_status != iNewStatus)
+ ProtoBroadcastAck(NULL, ACKTYPE_STATUS, ACKRESULT_SUCCESS, (HANDLE)old_status, iNewStatus);
+ }
+ else if (MCONTACT hContact = FindContact(skypename)) {
+ SetContactStatus(hContact, TeamsToMirandaStatus(status.c_str()));
+ if (auto &p = presence["lastActiveTime"])
+ setDword(hContact, "LastSeen", Utils_IsoToUnixTime(p.as_string().c_str()));
+ if (auto &p = presence["deviceType"])
+ setWString(hContact, "MirVer", L"Teams (" + p.as_mstring() + L")");
+ }
+ }
+}
+
+void CTeamsProto::ProcessServerMessage(const std::string &szName, int packetId, const JSONNode &args)
+{
+ if (szName == "trouter.message_loss")
+ TRouterSendJson("trouter.processed_message_loss", &args, packetId);
+
+ else if (szName == "trouter.connected")
+ TRouterSendActive(true, packetId);
+}
+
+void CTeamsProto::ProcessConversationUpdate(const JSONNode &node)
+{
+ if (auto &properties = node["threadProperties"]) {
+ CMStringW wszId(node["id"].as_mstring());
+ if (auto *si = Chat_Find(wszId, m_szModuleName))
+ if (getMStringW(si->hContact, "Version") != properties["version"].as_mstring())
+ GetChatInfo(wszId);
+ }
+}
+
+void CTeamsProto::ProcessThreadUpdate(const JSONNode &) {}
diff --git a/protocols/Teams/src/teams_utils.cpp b/protocols/Teams/src/teams_utils.cpp
new file mode 100644
index 0000000000..4fcc168a34
--- /dev/null
+++ b/protocols/Teams/src/teams_utils.cpp
@@ -0,0 +1,774 @@
+/*
+Copyright (c) 2025 Miranda NG team (https://miranda-ng.org)
+
+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 version 2
+of the License.
+
+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 warning(disable:4566)
+
+struct HtmlEntity
+{
+ const char *entity;
+ const char *symbol;
+};
+
+const HtmlEntity htmlEntities[] =
+{
+ { "AElig", "\u00C6" },
+ { "Aacute", "\u00C1" },
+ { "Acirc", "\u00C2" },
+ { "Agrave", "\u00C0" },
+ { "Alpha", "\u0391" },
+ { "Aring", "\u00C5" },
+ { "Atilde", "\u00C3" },
+ { "Auml", "\u00C4" },
+ { "Beta", "\u0392" },
+ { "Ccedil", "\u00C7" },
+ { "Chi", "\u03A7" },
+ { "Dagger", "‡" },
+ { "Delta", "\u0394" },
+ { "ETH", "\u00D0" },
+ { "Eacute", "\u00C9" },
+ { "Ecirc", "\u00CA" },
+ { "Egrave", "\u00C8" },
+ { "Epsilon", "\u0395" },
+ { "Eta", "\u0397" },
+ { "Euml", "\u00CB" },
+ { "Gamma", "\u0393" },
+ { "Iacute", "\u00CD" },
+ { "Icirc", "\u00CE" },
+ { "Igrave", "\u00CC" },
+ { "Iota", "\u0399" },
+ { "Iuml", "\u00CF" },
+ { "Kappa", "\u039A" },
+ { "Lambda", "\u039B" },
+ { "Mu", "\u039C" },
+ { "Ntilde", "\u00D1" },
+ { "Nu", "\u039D" },
+ { "OElig", "\u0152" },
+ { "Oacute", "\u00D3" },
+ { "Ocirc", "\u00D4" },
+ { "Ograve", "\u00D2" },
+ { "Omega", "\u03A9" },
+ { "Omicron", "\u039F" },
+ { "Oslash", "\u00D8" },
+ { "Otilde", "\u00D5" },
+ { "Ouml", "\u00D6" },
+ { "Phi", "\u03A6" },
+ { "Pi", "\u03A0" },
+ { "Prime", "\u2033" },
+ { "Psi", "\u03A8" },
+ { "Rho", "\u03A1" },
+ { "Scaron", "Š" },
+ { "Sigma", "Σ" },
+ { "THORN", "Þ" },
+ { "Tau", "Τ" },
+ { "Theta", "Θ" },
+ { "Uacute", "Ú" },
+ { "Ucirc", "Û" },
+ { "Ugrave", "Ù" },
+ { "Upsilon", "Υ" },
+ { "Uuml", "Ü" },
+ { "Xi", "Ξ" },
+ { "Yacute", "Ý" },
+ { "Yuml", "Ÿ" },
+ { "Zeta", "Ζ" },
+ { "aacute", "á" },
+ { "acirc", "â" },
+ { "acute", "´" },
+ { "aelig", "æ" },
+ { "agrave", "à" },
+ { "alefsym", "ℵ" },
+ { "alpha", "α" },
+ { "amp", "&" },
+ { "and", "∧" },
+ { "ang", "∠" },
+ { "apos", "'" },
+ { "aring", "å" },
+ { "asymp", "≈" },
+ { "atilde", "ã" },
+ { "auml", "ä" },
+ { "bdquo", "„" },
+ { "beta", "β" },
+ { "brvbar", "¦" },
+ { "bull", "•" },
+ { "cap", "∩" },
+ { "ccedil", "ç" },
+ { "cedil", "¸" },
+ { "cent", "¢" },
+ { "chi", "χ" },
+ { "circ", "ˆ" },
+ { "clubs", "♣" },
+ { "cong", "≅" },
+ { "copy", "©" },
+ { "crarr", "↵" },
+ { "cup", "∪" },
+ { "curren", "¤" },
+ { "dArr", "⇓" },
+ { "dagger", "†" },
+ { "darr", "↓" },
+ { "deg", "°" },
+ { "delta", "δ" },
+ { "diams", "♦" },
+ { "divide", "÷" },
+ { "eacute", "é" },
+ { "ecirc", "ê" },
+ { "egrave", "è" },
+ { "empty", "∅" },
+ { "emsp", " " },
+ { "ensp", " " },
+ { "epsilon", "ε" },
+ { "equiv", "≡" },
+ { "eta", "η" },
+ { "eth", "ð" },
+ { "euml", "ë" },
+ { "euro", "€" },
+ { "exist", "∃" },
+ { "fnof", "ƒ" },
+ { "forall", "∀" },
+ { "frac12", "½" },
+ { "frac14", "¼" },
+ { "frac34", "¾" },
+ { "frasl", "⁄" },
+ { "gamma", "γ" },
+ { "ge", "≥" },
+ { "gt", ">" },
+ { "hArr", "⇔" },
+ { "harr", "↔" },
+ { "hearts", "♥" },
+ { "hellip", "…" },
+ { "iacute", "í" },
+ { "icirc", "î" },
+ { "iexcl", "¡" },
+ { "igrave", "ì" },
+ { "image", "ℑ" },
+ { "infin", "∞" },
+ { "int", "∫" },
+ { "iota", "ι" },
+ { "iquest", "¿" },
+ { "isin", "∈" },
+ { "iuml", "ï" },
+ { "kappa", "κ" },
+ { "lArr", "⇐" },
+ { "lambda", "λ" },
+ { "lang", "〈" },
+ { "laquo", "«" },
+ { "larr", "←" },
+ { "lceil", "⌈" },
+ { "ldquo", "“" },
+ { "le", "≤" },
+ { "lfloor", "⌊" },
+ { "lowast", "∗" },
+ { "loz", "◊" },
+ { "lrm", "\xE2\x80\x8E" },
+ { "lsaquo", "‹" },
+ { "lsquo", "‘" },
+ { "lt", "<" },
+ { "macr", "¯" },
+ { "mdash", "—" },
+ { "micro", "µ" },
+ { "middot", "·" },
+ { "minus", "−" },
+ { "mu", "μ" },
+ { "nabla", "∇" },
+ { "nbsp", " " },
+ { "ndash", "–" },
+ { "ne", "≠" },
+ { "ni", "∋" },
+ { "not", "¬" },
+ { "notin", "∉" },
+ { "nsub", "⊄" },
+ { "ntilde", "ñ" },
+ { "nu", "ν" },
+ { "oacute", "ó" },
+ { "ocirc", "ô" },
+ { "oelig", "œ" },
+ { "ograve", "ò" },
+ { "oline", "‾" },
+ { "omega", "ω" },
+ { "omicron", "ο" },
+ { "oplus", "⊕" },
+ { "or", "∨" },
+ { "ordf", "ª" },
+ { "ordm", "º" },
+ { "oslash", "ø" },
+ { "otilde", "õ" },
+ { "otimes", "⊗" },
+ { "ouml", "ö" },
+ { "para", "¶" },
+ { "part", "∂" },
+ { "permil", "‰" },
+ { "perp", "⊥" },
+ { "phi", "φ" },
+ { "pi", "π" },
+ { "piv", "ϖ" },
+ { "plusmn", "±" },
+ { "pound", "£" },
+ { "prime", "′" },
+ { "prod", "∏" },
+ { "prop", "∝" },
+ { "psi", "ψ" },
+ { "quot", "\"" },
+ { "rArr", "⇒" },
+ { "radic", "√" },
+ { "rang", "〉" },
+ { "raquo", "»" },
+ { "rarr", "→" },
+ { "rceil", "⌉" },
+ { "rdquo", "”" },
+ { "real", "ℜ" },
+ { "reg", "®" },
+ { "rfloor", "⌋" },
+ { "rho", "ρ" },
+ { "rlm", "\xE2\x80\x8F" },
+ { "rsaquo", "›" },
+ { "rsquo", "’" },
+ { "sbquo", "‚" },
+ { "scaron", "š" },
+ { "sdot", "⋅" },
+ { "sect", "§" },
+ { "shy", "\xC2\xAD" },
+ { "sigma", "σ" },
+ { "sigmaf", "ς" },
+ { "sim", "∼" },
+ { "spades", "♠" },
+ { "sub", "⊂" },
+ { "sube", "⊆" },
+ { "sum", "∑" },
+ { "sup", "⊃" },
+ { "sup1", "¹" },
+ { "sup2", "²" },
+ { "sup3", "³" },
+ { "supe", "⊇" },
+ { "szlig", "ß" },
+ { "tau", "τ" },
+ { "there4", "∴" },
+ { "theta", "θ" },
+ { "thetasym", "ϑ" },
+ { "thinsp", " " },
+ { "thorn", "þ" },
+ { "tilde", "˜" },
+ { "times", "×" },
+ { "trade", "™" },
+ { "uArr", "⇑" },
+ { "uacute", "ú" },
+ { "uarr", "↑" },
+ { "ucirc", "û" },
+ { "ugrave", "ù" },
+ { "uml", "¨" },
+ { "upsih", "ϒ" },
+ { "upsilon", "υ" },
+ { "uuml", "ü" },
+ { "weierp", "℘" },
+ { "xi", "ξ" },
+ { "yacute", "ý" },
+ { "yen", "¥" },
+ { "yuml", "ÿ" },
+ { "zeta", "ζ" },
+ { "zwj", "\xE2\x80\x8D" },
+ { "zwnj", "\xE2\x80\x8C" }
+};
+
+static CMStringW getAttrText(const wchar_t *pwszText, const wchar_t *pwszAttrName)
+{
+ if (auto *p1 = mir_wstrstri(pwszText, pwszAttrName)) {
+ p1 += wcslen(pwszAttrName);
+ if (p1[0] == '=' && p1[1] == '\"') {
+ p1 += 2;
+ if (auto *p2 = wcschr(p1, '\"'))
+ return CMStringW(p1, p2 - p1);
+ }
+ }
+ return L"";
+}
+
+CMStringW CTeamsProto::RemoveHtml(const CMStringW &data)
+{
+ CMStringW new_string;
+
+ for (int i = 0; i < data.GetLength(); i++) {
+ wchar_t c = data[i];
+ if (c == '<') {
+ if (m_bUseBBCodes) {
+ bool bEnable = true;
+ auto *p = data.c_str() + i + 1;
+ if (*p == '/') {
+ bEnable = false;
+ p++;
+ }
+
+ if (!wcsncmp(p, L"b>", 2))
+ new_string.Append(bEnable ? L"[b]" : L"[/b]");
+ else if (!wcsncmp(p, L"i>", 2))
+ new_string.Append(bEnable ? L"[i]" : L"[/i]");
+ else if (!wcsncmp(p, L"u>", 2))
+ new_string.Append(bEnable ? L"[u]" : L"[/u]");
+ else if (!wcsncmp(p, L"s>", 2))
+ new_string.Append(bEnable ? L"[s]" : L"[/s]");
+ else if (!wcsncmp(p, L"pre ", 4))
+ new_string.Append(L"[code]");
+ else if (!wcsncmp(p, L"pre>", 4))
+ new_string.Append(L"[/code]");
+ else if (!wcsncmp(p, L"legacyquote>", 12)) {
+ if (bEnable)
+ i = data.Find(L"legacyquote>", i+13);
+ }
+ else if (!wcsncmp(p, L"quote ", 6)) {
+ CMStringW author(getAttrText(p, L"authorname"));
+ CMStringW timestamp(getAttrText(p, L"timestamp"));
+
+ wchar_t wszTime[100];
+ TimeZone_PrintTimeStamp(0, _wtoi(timestamp), L"D t", wszTime, _countof(wszTime), 0);
+
+ new_string.AppendFormat(L"[quote]%s %s %s: \r\n", wszTime, author.c_str(), TranslateT("wrote"));
+ }
+ else if (!wcsncmp(p, L"quote>", 6)) {
+ new_string.Append(L"[/quote]");
+ }
+ }
+
+ i = data.Find('>', i);
+ if (i == -1)
+ break;
+
+ continue;
+ }
+
+ // special character
+ if (c == '&') {
+ int begin = i;
+ i = data.Find(';', i);
+ if (i == -1)
+ i = begin;
+ else {
+ CMStringW entity = data.Mid(begin + 1, i - begin - 1);
+
+ bool found = false;
+ if (entity.GetLength() > 1 && entity[0] == '#') {
+ // Numeric replacement
+ bool hex = false;
+ if (entity[1] == 'x') {
+ hex = true;
+ entity.Delete(0, 2);
+ }
+ else entity.Delete(0, 1);
+
+ if (!entity.IsEmpty()) {
+ found = true;
+ errno = 0;
+ unsigned long value = wcstoul(entity, nullptr, hex ? 16 : 10);
+ if (errno != 0) { // error with conversion in strtoul, ignore the result
+ found = false;
+ }
+ else if (value <= 127) { // U+0000 .. U+007F
+ new_string += (char)value;
+ }
+ else if (value >= 128 && value <= 2047) { // U+0080 .. U+07FF
+ new_string += (char)(192 + (value / 64));
+ new_string += (char)(128 + (value % 64));
+ }
+ else if (value >= 2048 && value <= 65535) { // U+0800 .. U+FFFF
+ new_string += (char)(224 + (value / 4096));
+ new_string += (char)(128 + ((value / 64) % 64));
+ new_string += (char)(128 + (value % 64));
+ }
+ else {
+ new_string += (char)((value >> 24) & 0xFF);
+ new_string += (char)((value >> 16) & 0xFF);
+ new_string += (char)((value >> 8) & 0xFF);
+ new_string += (char)((value) & 0xFF);
+ }
+ }
+ }
+ else {
+ // Keyword replacement
+ CMStringA tmp = entity;
+ for (auto &it : htmlEntities) {
+ if (!mir_strcmpi(tmp, it.entity)) {
+ new_string += it.symbol;
+ found = true;
+ break;
+ }
+ }
+ }
+
+ if (found)
+ continue;
+ else
+ i = begin;
+ }
+ }
+
+ new_string.AppendChar(c);
+ }
+
+ return new_string;
+}
+
+bool AddBbcodes(CMStringA &str)
+{
+ bool bUsed = false;
+ CMStringA ret;
+
+ for (const char *p = str; *p; p++) {
+ if (*p == '[') {
+ p++;
+ if (!strncmp(p, "b]", 2)) {
+ p++;
+ ret.Append("<b _pre=\"*\" _post=\"*\">");
+ bUsed = true;
+ }
+ else if (!strncmp(p, "/b]", 3)) {
+ p += 2;
+ ret.Append("</b>");
+ bUsed = true;
+ }
+ else if (!strncmp(p, "i]", 2)) {
+ p++;
+ ret.Append("<i _pre=\"_\" _post=\"_\">");
+ bUsed = true;
+ }
+ else if (!strncmp(p, "/i]", 3)) {
+ p += 2;
+ ret.Append("</i>");
+ bUsed = true;
+ }
+ else if (!strncmp(p, "s]", 2)) {
+ p++;
+ ret.Append("<s _pre=\"~\" _post=\"~\">");
+ bUsed = true;
+ }
+ else if (!strncmp(p, "/s]", 3)) {
+ p += 2;
+ ret.Append("</s>");
+ bUsed = true;
+ }
+ else if (!strncmp(p, "code]", 5)) {
+ p += 4;
+ ret.Append("<pre _pre=\"```\" _post=\"```\">");
+ bUsed = true;
+ }
+ else if (!strncmp(p, "/code]", 6)) {
+ p += 5;
+ ret.Append("</pre>");
+ bUsed = true;
+ }
+ }
+ else ret.AppendChar(*p);
+ }
+
+ if (bUsed)
+ str = ret;
+ return bUsed;
+}
+
+//////////////////////////////////////////////////////////////////////////////////////////
+
+void Utf32toUtf16(uint32_t c, CMStringW &dest)
+{
+ if (c < 0x10000)
+ dest.AppendChar(c);
+ else {
+ unsigned int t = c - 0x10000;
+ dest.AppendChar((((t << 12) >> 22) + 0xD800));
+ dest.AppendChar((((t << 22) >> 22) + 0xDC00));
+ }
+}
+
+bool is_surrogate(wchar_t uc) { return (uc - 0xd800u) < 2048u; }
+bool is_high_surrogate(wchar_t uc) { return (uc & 0xfffffc00) == 0xd800; }
+bool is_low_surrogate(wchar_t uc) { return (uc & 0xfffffc00) == 0xdc00; }
+
+uint32_t Utf16toUtf32(const wchar_t *str)
+{
+ if (!is_surrogate(str[0]))
+ return str[0];
+
+ if (is_high_surrogate(str[0]) && is_low_surrogate(str[1]))
+ return (str[0] << 10) + str[1] - 0x35fdc00;
+
+ return 0;
+}
+
+//////////////////////////////////////////////////////////////////////////////////////////
+
+int TeamsToMirandaStatus(const char *status)
+{
+ if (!mir_strcmpi(status, "Available"))
+ return ID_STATUS_ONLINE;
+ if (!mir_strcmpi(status, "Away"))
+ return ID_STATUS_AWAY;
+ if (!mir_strcmpi(status, "BeRightBack"))
+ return ID_STATUS_NA;
+ if (!mir_strcmpi(status, "AvailableIdle"))
+ return ID_STATUS_IDLE;
+ if (!mir_strcmpi(status, "Busy"))
+ return ID_STATUS_OCCUPIED;
+ if (!mir_strcmpi(status, "DoNotDisturb"))
+ return ID_STATUS_DND;
+ return ID_STATUS_OFFLINE;
+}
+
+//////////////////////////////////////////////////////////////////////////////////////////
+
+bool CTeamsProto::IsMe(const wchar_t *str)
+{
+ return (!mir_wstrcmpi(str, Utf2T(m_szMyname)) || !mir_wstrcmp(str, Utf2T(m_szOwnSkypeId)));
+}
+
+bool CTeamsProto::IsMe(const char *str)
+{
+ return (!mir_strcmpi(str, m_szMyname) || !mir_strcmp(str, m_szOwnSkypeId));
+}
+
+//////////////////////////////////////////////////////////////////////////////////////////
+
+int64_t CTeamsProto::getLastTime(MCONTACT hContact)
+{
+ ptrA szLastTime(getStringA(hContact, "LastMsgTime"));
+ return (szLastTime) ? _atoi64(szLastTime) : 0;
+}
+
+void CTeamsProto::setLastTime(MCONTACT hContact, int64_t iValue)
+{
+ char buf[100];
+ _i64toa(iValue, buf, 10);
+ setString(hContact, "LastMsgTime", buf);
+
+ if (iValue > getLastTime(0))
+ setString("LastMsgTime", buf);
+}
+
+//////////////////////////////////////////////////////////////////////////////////////////
+
+bool CTeamsProto::IsFileExists(std::wstring path)
+{
+ return _waccess(path.c_str(), 0) == 0;
+}
+
+int64_t getRandomId()
+{
+ int64_t ret;
+ Utils_GetRandom(&ret, sizeof(ret));
+ if (ret < 0)
+ ret = -ret;
+ return ret;
+}
+
+CMStringA getMessageId(const JSONNode &node)
+{
+ CMStringA ret(node["skypeeditedid"].as_mstring());
+ if (ret.IsEmpty())
+ ret = node["clientmessageid"].as_mstring();
+ return ret;
+}
+
+const char* GetSkypeNick(const char *szSkypeId)
+{
+ if (auto *p = strchr(szSkypeId, ':'))
+ return p + 1;
+ return szSkypeId;
+}
+
+const wchar_t* GetSkypeNick(const wchar_t *szSkypeId)
+{
+ if (auto *p = wcsrchr(szSkypeId, ':'))
+ return p + 1;
+ return szSkypeId;
+}
+
+void CTeamsProto::SetString(MCONTACT hContact, const char *pszSetting, const JSONNode &node)
+{
+ CMStringW str = node.as_mstring();
+ if (str.IsEmpty() || str == L"null")
+ delSetting(hContact, pszSetting);
+ else
+ setWString(hContact, pszSetting, str);
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+// url parsing
+
+CMStringA ParseUrl(const char *url, const char *token)
+{
+ if (url == nullptr)
+ return CMStringA();
+
+ auto *start = strstr(url, token);
+ if (start == nullptr)
+ return CMStringA();
+
+ auto *end = strchr(++start, '/');
+ if (end == nullptr)
+ return CMStringA(start);
+ return CMStringA(start, end - start);
+}
+
+CMStringW ParseUrl(const wchar_t *url, const wchar_t *token)
+{
+ if (url == nullptr)
+ return CMStringW();
+
+ auto *start = wcsstr(url, token);
+ if (start == nullptr)
+ return CMStringW();
+
+ auto *end = wcschr(++start, '/');
+ if (end == nullptr)
+ return CMStringW(start);
+ return CMStringW(start, end - start);
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+static int possibleTypes[] = { 1, 2, 4, 8, 19, 28 };
+
+bool IsPossibleUserType(const char *pszUserId)
+{
+ int iType = atoi(pszUserId);
+
+ // we don't process 28 and other shit
+ for (auto &it : possibleTypes)
+ if (it == iType)
+ return true;
+
+ return false;
+}
+
+CMStringA UrlToSkypeId(const char *url, int *pUserType)
+{
+ int userType = -1;
+ CMStringA szResult;
+
+ if (url != nullptr) {
+ for (auto &it : possibleTypes) {
+ char tmp[10];
+ sprintf_s(tmp, "/%d:", it);
+ if (strstr(url, tmp)) {
+ userType = it;
+ szResult = ParseUrl(url, tmp);
+ break;
+ }
+ }
+ }
+
+ if (pUserType)
+ *pUserType = userType;
+
+ return szResult;
+}
+
+CMStringW UrlToSkypeId(const wchar_t *url, int *pUserType)
+{
+ int userType = -1;
+ CMStringW szResult;
+
+ if (url != nullptr) {
+ for (auto &it : possibleTypes) {
+ wchar_t tmp[10];
+ swprintf_s(tmp, L"/%d:", it);
+ if (wcsstr(url, tmp)) {
+ userType = it;
+ szResult = ParseUrl(url, tmp);
+ break;
+ }
+ }
+ }
+
+ if (pUserType)
+ *pUserType = userType;
+
+ return szResult;
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+INT_PTR CTeamsProto::ParseSkypeUriService(WPARAM, LPARAM lParam)
+{
+ wchar_t *arg = (wchar_t *)lParam;
+ if (arg == nullptr)
+ return 1;
+
+ // skip leading prefix
+ wchar_t szUri[1024];
+ wcsncpy_s(szUri, arg, _TRUNCATE);
+ wchar_t *szJid = wcschr(szUri, ':');
+ if (szJid == nullptr)
+ return 1;
+
+ // empty jid?
+ if (!*szJid)
+ return 1;
+
+ // command code
+ wchar_t *szCommand = szJid;
+ szCommand = wcschr(szCommand, '?');
+ if (szCommand)
+ *(szCommand++) = 0;
+
+ // parameters
+ wchar_t *szSecondParam = szCommand ? wcschr(szCommand, '&') : nullptr;
+ if (szSecondParam)
+ *(szSecondParam++) = 0;
+
+ // no command or message command
+ if (!szCommand || !mir_wstrcmpi(szCommand, L"chat")) {
+ if (szSecondParam) {
+ wchar_t *szChatId = wcsstr(szSecondParam, L"id=");
+ if (szChatId) {
+ szChatId += 5;
+ StartChatRoom(szChatId, szChatId);
+ return 0;
+ }
+ }
+ MCONTACT hContact = AddContact(_T2A(szJid), nullptr, true);
+ CallService(MS_MSG_SENDMESSAGE, (WPARAM)hContact, NULL);
+ return 0;
+ }
+
+ if (!mir_wstrcmpi(szCommand, L"call")) {
+ MCONTACT hContact = AddContact(_T2A(szJid), nullptr, true);
+ NotifyEventHooks(g_hCallEvent, (WPARAM)hContact, (LPARAM)0);
+ return 0;
+ }
+
+ if (!mir_wstrcmpi(szCommand, L"userinfo"))
+ return 0;
+
+ if (!mir_wstrcmpi(szCommand, L"add")) {
+ MCONTACT hContact = FindContact(_T2A(szJid));
+ if (hContact == NULL) {
+ PROTOSEARCHRESULT psr = { 0 };
+ psr.cbSize = sizeof(psr);
+ psr.id.w = mir_wstrdup(szJid);
+ psr.nick.w = mir_wstrdup(szJid);
+ psr.flags = PSR_UNICODE;
+ Contact::AddBySearch(m_szModuleName, &psr);
+ }
+ return 0;
+ }
+
+ if (!mir_wstrcmpi(szCommand, L"sendfile")) {
+ MCONTACT hContact = AddContact(_T2A(szJid), nullptr, true);
+ File::Send(hContact);
+ return 1;
+ }
+
+ if (!mir_wstrcmpi(szCommand, L"voicemail"))
+ return 1;
+
+ return 1;
+}
diff --git a/protocols/Teams/src/teams_utils.h b/protocols/Teams/src/teams_utils.h
new file mode 100644
index 0000000000..59931a4a3a
--- /dev/null
+++ b/protocols/Teams/src/teams_utils.h
@@ -0,0 +1,75 @@
+/*
+Copyright (c) 2015-25 Miranda NG team (https://miranda-ng.org)
+
+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 version 2
+of the License.
+
+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/>.
+*/
+
+#ifndef _UTILS_H_
+#define _UTILS_H_
+
+void Utf32toUtf16(uint32_t c, CMStringW &dest);
+uint32_t Utf16toUtf32(const wchar_t *str);
+
+const char* GetSkypeNick(const char *pszSkypeId);
+const wchar_t* GetSkypeNick(const wchar_t *szSkypeId);
+
+CMStringA ParseUrl(const char *url, const char *token);
+
+int TeamsToMirandaStatus(const char *status);
+
+bool AddBbcodes(CMStringA &str);
+
+bool IsPossibleUserType(const char *pszUserId);
+
+CMStringA UrlToSkypeId(const char *url, int *pUserType = nullptr);
+CMStringW UrlToSkypeId(const wchar_t *url, int *pUserType = nullptr);
+
+int getMoodIndex(const char *pszMood);
+
+int64_t getRandomId();
+CMStringA getMessageId(const JSONNode &node);
+
+struct CFileUploadParam : public MZeroedObject
+{
+ OBJLIST<wchar_t> arFileName;
+ ptrW tszDesc;
+ ptrA atr;
+ ptrA fname;
+ ptrA uid;
+ long size;
+ int width, height;
+ MCONTACT hContact;
+ bool isPicture;
+
+ CFileUploadParam(MCONTACT _hContact, wchar_t **_files, const wchar_t* _desc) :
+ arFileName(1),
+ hContact(_hContact),
+ tszDesc(mir_wstrdup(_desc))
+ {
+ for (auto p = _files; *p != 0; p++)
+ arFileName.insert(newStrW(*p));
+ }
+};
+
+struct TeamsReply : public JsonReply
+{
+ TeamsReply(MHttpResponse *response) :
+ JsonReply(response)
+ {
+ if (m_root)
+ m_errorCode = (*m_root)["status"]["code"].as_int();
+ }
+};
+
+#endif //_UTILS_H_
diff --git a/protocols/Teams/src/version.h b/protocols/Teams/src/version.h
new file mode 100644
index 0000000000..3b23908bca
--- /dev/null
+++ b/protocols/Teams/src/version.h
@@ -0,0 +1,13 @@
+#define __MAJOR_VERSION 0
+#define __MINOR_VERSION 96
+#define __RELEASE_NUM 6
+#define __BUILD_NUM 1
+
+#include <stdver.h>
+
+#define __PLUGIN_NAME "Teams protocol"
+#define __FILENAME "Teams.dll"
+#define __DESCRIPTION "Microsoft Teams protocol support for Miranda NG."
+#define __AUTHOR "Miranda NG team"
+#define __AUTHORWEB "https://miranda-ng.org/p/Teams"
+#define __COPYRIGHT "© 2025 Miranda NG team"