From c500e50812be6919257b406344218cf6ab69b95f Mon Sep 17 00:00:00 2001
From: George Hazan <ghazan@miranda.im>
Date: Sat, 12 Nov 2022 14:54:25 +0300
Subject: WhatsApp:

- attempt to implement group chat encryption;
- signal session loading optimization;
- support for image, video & audio messages
---
 protocols/WhatsApp/src/chats.cpp   | 110 ++++++++++++++++++++++++++++---------
 protocols/WhatsApp/src/message.cpp |  68 ++++++++++++++++++-----
 protocols/WhatsApp/src/proto.cpp   |   3 +
 protocols/WhatsApp/src/proto.h     |  21 ++++---
 protocols/WhatsApp/src/server.cpp  |   6 +-
 protocols/WhatsApp/src/signal.cpp  |  80 ++++++++++++++++++++++-----
 protocols/WhatsApp/src/utils.cpp   |  52 +++++++++++++++++-
 7 files changed, 274 insertions(+), 66 deletions(-)

(limited to 'protocols/WhatsApp/src')

diff --git a/protocols/WhatsApp/src/chats.cpp b/protocols/WhatsApp/src/chats.cpp
index 8861693fca..774df86db9 100644
--- a/protocols/WhatsApp/src/chats.cpp
+++ b/protocols/WhatsApp/src/chats.cpp
@@ -7,38 +7,40 @@ Copyright © 2019-22 George Hazan
 
 #include "stdafx.h"
 
-void WhatsAppProto::GC_Init(WAUser *pUser)
+void WhatsAppProto::GC_GetAllMetadata()
 {
-	CMStringW wszId(Utf2T(pUser->szId));
-
-	pUser->si = Chat_NewSession(GCW_CHATROOM, m_szModuleName, wszId, getMStringW(pUser->hContact, "Nick"));
-
-	Chat_AddGroup(pUser->si, TranslateT("Owner"));
-	Chat_AddGroup(pUser->si, TranslateT("SuperAdmin"));
-	Chat_AddGroup(pUser->si, TranslateT("Admin"));
-	Chat_AddGroup(pUser->si, TranslateT("Participant"));
-
-	if (pUser->bInited) {
-		Chat_Control(m_szModuleName, wszId, m_bHideGroupchats ? WINDOW_HIDDEN : SESSION_INITDONE);
-		Chat_Control(m_szModuleName, wszId, SESSION_ONLINE);
-	}
-	else GC_GetMetadata(pUser->szId);
+	WANodeIq iq(IQ::GET, "w:g2", "@g.us");
+	auto *pRoot = iq.addChild("participating");
+	*pRoot << XCHILD("participants") << XCHILD("description");
+	WSSendNode(iq, &WhatsAppProto::OnIqGcGetAllMetadata);
 }
 
-void WhatsAppProto::GC_GetMetadata(const char *szId)
+void WhatsAppProto::OnIqGcGetAllMetadata(const WANode &node)
 {
-	WANodeIq iq(IQ::GET, "w:g2", szId);
-	iq.addChild("query")->addAttr("request", "interactive");
-	WSSendNode(iq, &WhatsAppProto::OnIqGcMetadata);
+	if (auto *pGroup = node.getChild("groups"))
+		for (auto &it : pGroup->getChildren())
+			GC_ParseMetadata(it);	
 }
 
-void WhatsAppProto::OnIqGcMetadata(const WANode &node)
+void WhatsAppProto::GC_ParseMetadata(const WANode *pGroup)
 {
-	auto *pGroup = node.getChild("group");
-	auto *pChatUser = FindUser(node.getAttr("from"));
-	if (pChatUser == nullptr || pGroup == nullptr)
+	auto *pszId = pGroup->getAttr("id");
+	if (pszId == nullptr)
 		return;
 
+	auto *pChatUser = AddUser(CMStringA(pszId) + "@g.us", false);
+	if (pChatUser == nullptr)
+		return;
+
+	CMStringW wszId(Utf2T(pChatUser->szId));
+
+	pChatUser->si = Chat_NewSession(GCW_CHATROOM, m_szModuleName, wszId, getMStringW(pChatUser->hContact, "Nick"));
+
+	Chat_AddGroup(pChatUser->si, TranslateT("Owner"));
+	Chat_AddGroup(pChatUser->si, TranslateT("SuperAdmin"));
+	Chat_AddGroup(pChatUser->si, TranslateT("Admin"));
+	Chat_AddGroup(pChatUser->si, TranslateT("Participant"));
+
 	CMStringA szOwner(pGroup->getAttr("creator")), szNick, szRole;
 
 	for (auto &it : pGroup->getChildren()) {
@@ -57,7 +59,7 @@ void WhatsAppProto::OnIqGcMetadata(const WANode &node)
 		}
 		else if (it->title == "participant") {
 			auto *jid = it->getAttr("jid");
-			
+
 			// if role isn't specified, use the default one
 			auto *role = it->getAttr("type");
 			if (role == nullptr)
@@ -112,7 +114,65 @@ void WhatsAppProto::OnIqGcMetadata(const WANode &node)
 	}
 
 	pChatUser->bInited = true;
-	CMStringW wszId(Utf2T(pChatUser->szId));
 	Chat_Control(m_szModuleName, wszId, m_bHideGroupchats ? WINDOW_HIDDEN : SESSION_INITDONE);
 	Chat_Control(m_szModuleName, wszId, SESSION_ONLINE);
 }
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+int WhatsAppProto::GcEventHook(WPARAM, LPARAM lParam)
+{
+	GCHOOK *gch = (GCHOOK*)lParam;
+	if (gch == nullptr)
+		return 0;
+
+	if (mir_strcmpi(gch->si->pszModule, m_szModuleName))
+		return 0;
+
+	auto *pUser = FindUser(T2Utf(gch->si->ptszID));
+	if (pUser == nullptr)
+		return 0;
+
+	switch (gch->iType) {
+	case GC_USER_MESSAGE:
+		if (gch->ptszText && mir_wstrlen(gch->ptszText) > 0) {
+			rtrimw(gch->ptszText);
+			Chat_UnescapeTags(gch->ptszText);
+			SendTextMessage(pUser->szId, T2Utf(gch->ptszText));
+		}
+		break;
+
+	case GC_USER_PRIVMESS:
+		break;
+
+	case GC_USER_LOGMENU:
+		break;
+
+	case GC_USER_NICKLISTMENU:
+		break;
+	}
+
+	return 1;
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+
+int WhatsAppProto::GcMenuHook(WPARAM, LPARAM lParam)
+{
+	GCMENUITEMS* gcmi = (GCMENUITEMS*)lParam;
+	if (gcmi == nullptr)
+		return 0;
+
+	if (mir_strcmpi(gcmi->pszModule, m_szModuleName))
+		return 0;
+
+	auto *pUser = FindUser(T2Utf(gcmi->pszID));
+	if (pUser == nullptr)
+		return 0;
+
+	if (gcmi->Type == MENU_ON_LOG) {
+	}
+	else if (gcmi->Type == MENU_ON_NICKLIST) {
+	}
+	return 0;
+}
diff --git a/protocols/WhatsApp/src/message.cpp b/protocols/WhatsApp/src/message.cpp
index 32d42cad3d..3d1bf5e872 100644
--- a/protocols/WhatsApp/src/message.cpp
+++ b/protocols/WhatsApp/src/message.cpp
@@ -358,31 +358,71 @@ bool WhatsAppProto::CreateMsgParticipant(WANode *pParticipants, const WAJid &jid
 
 int WhatsAppProto::SendTextMessage(const char *jid, const char *pszMsg)
 {
+	WAJid toJid(jid);
+
 	char szMsgId[40];
 	__int64 msgId;
 	Utils_GetRandom(&msgId, sizeof(msgId));
 	_i64toa(msgId, szMsgId, 10);
 
+	// mother node for all participants
+	WANode payLoad("message");
+	payLoad << CHAR_PARAM("id", szMsgId) << CHAR_PARAM("type", "text") << CHAR_PARAM("to", jid);
+
+	auto *pParticipants = payLoad.addChild("participants");
+
+	// basic message 
 	Wa__Message body;
 	body.conversation = (char*)pszMsg;
 
-	Wa__Message__DeviceSentMessage sentBody;
-	sentBody.message = &body;
-	sentBody.destinationjid = (char*)jid;
+	bool shouldIncludeIdentity = false;
 
-	Wa__Message msg;
-	msg.devicesentmessage = &sentBody;
+	if (toJid.isGroup()) {
+		MBinBuffer encodedMsg(proto::Serialize(&body));
+		padBuffer16(encodedMsg);
 
-	MBinBuffer encMsg(proto::Serialize(&msg));
-	padBuffer16(encMsg);	
+		MBinBuffer skmsgKey;
+		MBinBuffer cipherText(m_signalStore.encryptSenderKey(toJid, m_szJid, encodedMsg, skmsgKey));
 
-	WANode payLoad("message");
-	payLoad << CHAR_PARAM("id", szMsgId) << CHAR_PARAM("type", "text") << CHAR_PARAM("to", jid);
-	
-	auto *pParticipants = payLoad.addChild("participants");
-	bool shouldIncludeIdentity = CreateMsgParticipant(pParticipants, WAJid(jid), encMsg);
-	for (auto &it : m_arDevices)
-		shouldIncludeIdentity |= CreateMsgParticipant(pParticipants, it->jid, encMsg);
+		auto *pEnc = payLoad.addChild("enc");
+		*pEnc << CHAR_PARAM("v", "2") << CHAR_PARAM("type", "skmsg");
+		pEnc->content.append(cipherText.data(), cipherText.length());
+
+		Wa__Message__SenderKeyDistributionMessage sentBody;
+		sentBody.axolotlsenderkeydistributionmessage.data = skmsgKey.data();
+		sentBody.axolotlsenderkeydistributionmessage.len = skmsgKey.length();
+		sentBody.has_axolotlsenderkeydistributionmessage = true;
+		sentBody.groupid = (char*)jid;
+
+		Wa__Message msg;
+		msg.senderkeydistributionmessage = &sentBody;
+
+		MBinBuffer encodedMeMsg(proto::Serialize(&msg));
+		padBuffer16(encodedMeMsg);
+
+		if (auto *pUser = FindUser(jid))
+			if (pUser->si)
+				for (auto &it : pUser->si->arUsers)
+					shouldIncludeIdentity = CreateMsgParticipant(pParticipants, WAJid(T2Utf(it->pszUID)), encodedMeMsg);
+
+		for (auto &it : m_arDevices)
+			shouldIncludeIdentity |= CreateMsgParticipant(pParticipants, it->jid, encodedMeMsg);
+	}
+	else {
+		Wa__Message__DeviceSentMessage sentBody;
+		sentBody.message = &body;
+		sentBody.destinationjid = (char*)jid;
+
+		Wa__Message msg;
+		msg.devicesentmessage = &sentBody;
+
+		MBinBuffer encodedMeMsg(proto::Serialize(&msg));
+		padBuffer16(encodedMeMsg);
+
+		shouldIncludeIdentity = CreateMsgParticipant(pParticipants, toJid, encodedMeMsg);
+		for (auto &it : m_arDevices)
+			shouldIncludeIdentity |= CreateMsgParticipant(pParticipants, it->jid, encodedMeMsg);
+	}
 
 	if (shouldIncludeIdentity) {
 		MBinBuffer encIdentity(m_signalStore.encodeSignedIdentity(true));
diff --git a/protocols/WhatsApp/src/proto.cpp b/protocols/WhatsApp/src/proto.cpp
index d101a26255..fbc81be06f 100644
--- a/protocols/WhatsApp/src/proto.cpp
+++ b/protocols/WhatsApp/src/proto.cpp
@@ -97,6 +97,9 @@ WhatsAppProto::WhatsAppProto(const char *proto_name, const wchar_t *username) :
 	gcr.ptszDispName = m_tszUserName;
 	gcr.pszModule = m_szModuleName;
 	Chat_Register(&gcr);
+
+	HookProtoEvent(ME_GC_EVENT, &WhatsAppProto::GcEventHook);
+	HookProtoEvent(ME_GC_BUILDMENU, &WhatsAppProto::GcMenuHook);
 }
 
 WhatsAppProto::~WhatsAppProto()
diff --git a/protocols/WhatsApp/src/proto.h b/protocols/WhatsApp/src/proto.h
index 4f2fee4609..4d5c8773b7 100644
--- a/protocols/WhatsApp/src/proto.h
+++ b/protocols/WhatsApp/src/proto.h
@@ -190,24 +190,27 @@ public:
 	{
 		MBinBuffer priv, pub;
 	}
-	signedIdentity;
+		signedIdentity;
 
 	struct
 	{
 		MBinBuffer priv, pub, signature;
 		uint32_t keyid;
-	} preKey;
+	}
+		preKey;
 
 	MSignalStore(PROTO_INTERFACE *_1, const char *_2);
 	~MSignalStore();
 
 	__forceinline signal_context *CTX() const { return m_pContext; }
 
-	MSignalSession *createSession(const CMStringA &szName, int deviceId);
+	MSignalSession* createSession(const CMStringA &szName, int deviceId);
+	MSignalSession* getSession(const signal_protocol_address *address);
 
 	MBinBuffer decryptSignalProto(const CMStringA &from, const char *pszType, const MBinBuffer &encrypted);
 	MBinBuffer decryptGroupSignalProto(const CMStringA &from, const CMStringA &author, const MBinBuffer &encrypted);
 
+	MBinBuffer encryptSenderKey(const WAJid &to, const CMStringA &from, const MBinBuffer &buf, MBinBuffer &skmsgKey);
 	MBinBuffer encryptSignalProto(const WAJid &to, const MBinBuffer &buf, int &type);
 
 	MBinBuffer encodeSignedIdentity(bool);
@@ -288,8 +291,11 @@ class WhatsAppProto : public PROTO<WhatsAppProto>
 
 	// Group chats /////////////////////////////////////////////////////////////////////////
 
-	void GC_Init(WAUser *pUser);
-	void GC_GetMetadata(const char *szJid);
+	void GC_GetAllMetadata();
+	void GC_ParseMetadata(const WANode *pGroup);
+
+	int  __cdecl GcEventHook(WPARAM, LPARAM);
+	int  __cdecl GcMenuHook(WPARAM, LPARAM);
 
 	// UI //////////////////////////////////////////////////////////////////////////////////
 
@@ -311,6 +317,7 @@ class WhatsAppProto : public PROTO<WhatsAppProto>
 	uint16_t m_wMsgPrefix[2];
 	CMStringA GenerateMessageId();
 	CMStringA GetMessageText(const Wa__Message *pMessage);
+	void GetMessageContent(CMStringA &txt, const char *szType, const char *szUrl, const char *szMimetype, const char *szDirectPath, const ProtobufCBinaryData &szMediaKey, const char *szCaption = nullptr);
 	void ProcessMessage(WAMSG type, const Wa__WebMessageInfo &msg);
 	bool CreateMsgParticipant(WANode *pParticipants, const WAJid &jid, const MBinBuffer &orig);
 
@@ -345,8 +352,6 @@ class WhatsAppProto : public PROTO<WhatsAppProto>
 
 	/// Request handlers ///////////////////////////////////////////////////////////////////
 
-	void OnGetChatInfo(const JSONNode &node, void*);
-
 	void OnProcessHandshake(const uint8_t *pData, int cbLen);
 	
 	void InitPersistentHandlers();
@@ -354,7 +359,7 @@ class WhatsAppProto : public PROTO<WhatsAppProto>
 	void OnIqBlockList(const WANode &node);
 	void OnIqCountPrekeys(const WANode &node);
 	void OnIqDoNothing(const WANode &node);
-	void OnIqGcMetadata(const WANode &node);
+	void OnIqGcGetAllMetadata(const WANode &node);
 	void OnIqGetAvatar(const WANode &node);
 	void OnIqGetUsync(const WANode &node);
 	void OnIqPairDevice(const WANode &node);
diff --git a/protocols/WhatsApp/src/server.cpp b/protocols/WhatsApp/src/server.cpp
index 5dfbd34270..16cdb86a79 100644
--- a/protocols/WhatsApp/src/server.cpp
+++ b/protocols/WhatsApp/src/server.cpp
@@ -312,8 +312,10 @@ void WhatsAppProto::OnLoggedIn()
 		&WhatsAppProto::OnIqDoNothing);
 
 	for (auto &it : m_arUsers)
-		if (it->bIsGroupChat)
-			GC_Init(it);
+		if (it->bIsGroupChat) {
+			GC_GetAllMetadata();
+			return;
+		}
 }
 
 void WhatsAppProto::OnLoggedOut(void)
diff --git a/protocols/WhatsApp/src/signal.cpp b/protocols/WhatsApp/src/signal.cpp
index d8d8f2c2b3..47bf9462af 100644
--- a/protocols/WhatsApp/src/signal.cpp
+++ b/protocols/WhatsApp/src/signal.cpp
@@ -110,10 +110,8 @@ static int encrypt_func(signal_buffer **output,
 static int contains_session_func(const signal_protocol_address *address, void *user_data)
 {
 	auto *pStore = (MSignalStore *)user_data;
-
-	MSignalSession tmp(CMStringA(address->name, (int)address->name_len), address->device_id);
-	ptrA data(pStore->pProto->getStringA(tmp.getSetting()));
-	return data != nullptr;
+	auto *pSession = pStore->getSession(address);
+	return pSession != nullptr;
 }
 
 static int delete_all_sessions_func(const char *name, size_t name_len, void *user_data)
@@ -171,18 +169,11 @@ int load_session_func(signal_buffer **record, signal_buffer **user_data_storage,
 {
 	auto *pStore = (MSignalStore *)user_data;
 
-	MSignalSession tmp(CMStringA(address->name, (int)address->name_len), address->device_id);
-	auto *pSession = pStore->arSessions.find(&tmp);
-	if (pSession == nullptr) {
-		MBinBuffer blob(pStore->pProto->getBlob(tmp.getSetting()));
-		if (blob.data() == nullptr)
-			return 0;
-
-		pSession = new MSignalSession(tmp);
-		pSession->sessionData.assign(blob.data(), blob.length());
-	}
+	auto *pSession = pStore->getSession(address);
+	if (pSession == nullptr)
+		return 0;
 		
-	*record = signal_buffer_create((uint8_t *)pSession->sessionData.data(), pSession->sessionData.length());
+	*record = signal_buffer_create(pSession->sessionData.data(), pSession->sessionData.length());
 	*user_data_storage = 0;
 	return 1;
 }
@@ -521,6 +512,23 @@ MSignalSession* MSignalStore::createSession(const CMStringA &szName, int deviceI
 	return pSession;
 }
 
+MSignalSession* MSignalStore::getSession(const signal_protocol_address *address)
+{
+	MSignalSession tmp(CMStringA(address->name, (int)address->name_len), address->device_id);
+	auto *pSession = arSessions.find(&tmp);
+	if (pSession == nullptr) {
+		MBinBuffer blob(pProto->getBlob(tmp.getSetting()));
+		if (blob.data() == nullptr)
+			return nullptr;
+
+		pSession = new MSignalSession(tmp);
+		pSession->sessionData.assign(blob.data(), blob.length());
+		arSessions.insert(pSession);
+	}
+
+	return pSession;
+}
+
 /////////////////////////////////////////////////////////////////////////////////////////
 
 MBinBuffer MSignalStore::decryptSignalProto(const CMStringA &from, const char *pszType, const MBinBuffer &encrypted)
@@ -625,6 +633,48 @@ void MSignalStore::processSenderKeyMessage(const CMStringA &author, const Wa__Me
 /////////////////////////////////////////////////////////////////////////////////////////
 // encryption
 
+MBinBuffer MSignalStore::encryptSenderKey(const WAJid &to, const CMStringA &from, const MBinBuffer &buf, MBinBuffer &skmsgKey)
+{
+	signal_protocol_sender_key_name senderKeyName;
+	senderKeyName.group_id = to.user.c_str();
+	senderKeyName.group_id_len = to.user.GetLength();
+	senderKeyName.sender.device_id = 0;
+	senderKeyName.sender.name = from.c_str();
+	senderKeyName.sender.name_len = from.GetLength();
+
+	group_session_builder *builder;
+	logError(
+		group_session_builder_create(&builder, m_pStore, m_pContext),
+		"unable to create session builder");
+
+	sender_key_distribution_message *skmsg;
+	logError(
+		group_session_builder_create_session(builder, &skmsg, &senderKeyName),
+		"unable to create session");
+
+	group_cipher *cipher;
+	logError(
+		group_cipher_create(&cipher, m_pStore, &senderKeyName, m_pContext),
+		"unable to create group cipher");
+
+	ciphertext_message *encMessage;
+	logError(
+		group_cipher_encrypt(cipher, buf.data(), buf.length(), &encMessage),
+		"unable to encrypt group message");
+
+	MBinBuffer res;
+	auto *cipherText = ciphertext_message_get_serialized(encMessage);
+	res.assign(cipherText->data, cipherText->len);
+
+	auto *pKey = sender_key_distribution_message_get_signature_key(skmsg);
+	skmsgKey.assign(pKey->data, sizeof(pKey->data));
+
+	sender_key_distribution_message_destroy((signal_type_base*)skmsg);
+	group_cipher_free(cipher);
+	group_session_builder_free(builder);
+	return res;
+}
+
 MBinBuffer MSignalStore::encryptSignalProto(const WAJid &to, const MBinBuffer &buf, int &type)
 {
 	auto *pSession = createSession(to.user, to.device);
diff --git a/protocols/WhatsApp/src/utils.cpp b/protocols/WhatsApp/src/utils.cpp
index a28deda31d..4f11f5821c 100644
--- a/protocols/WhatsApp/src/utils.cpp
+++ b/protocols/WhatsApp/src/utils.cpp
@@ -120,8 +120,6 @@ WAUser* WhatsAppProto::AddUser(const char *szId, bool bTemporary)
 	if (pUser->bIsGroupChat) {
 		setByte(hContact, "ChatRoom", 1);
 		setString(hContact, "ChatRoomID", szId);
-
-		GC_Init(pUser);
 	}
 	else {
 		setString(hContact, DBKEY_ID, szId);
@@ -418,6 +416,47 @@ CMStringA file2string(const wchar_t *pwszFileName)
 	return res;
 }
 
+void WhatsAppProto::GetMessageContent(
+	CMStringA &txt, 
+	const char *szType,
+	const char *szMimeType,
+	const char *szUrl,
+	const char *szDirectPath,
+	const ProtobufCBinaryData &pMediaKey,
+	const char *szCaption)
+{
+	if (szCaption) {
+		if (m_bUseBbcodes)
+			txt.Append("<b>");
+		txt.Append(szCaption);
+		if (m_bUseBbcodes)
+			txt.Append("</b>");
+		txt.Append("\n");
+	}
+
+	CMStringA url = szUrl;
+	int idx = url.ReverseFind('/');
+	if (idx != -1)
+		url.Delete(0, idx+1);
+	idx = url.ReverseFind('.');
+	if (idx != -1)
+		url.Truncate(idx);
+	if (szMimeType)
+		url.Append(_T2A(ProtoGetAvatarExtension(ProtoGetAvatarFormatByMimeType(szMimeType))));
+
+	char *szMediaType = NEWSTR_ALLOCA(szType);
+	szMediaType[0] = toupper(szMediaType[0]);
+
+	MBinBuffer buf = DownloadEncryptedFile(directPath2url(szDirectPath), pMediaKey, szMediaType);
+	if (buf.data()) {
+		CMStringW pwszFileName(GetTmpFileName(szType, url));
+		bin2file(buf, pwszFileName);
+
+		pwszFileName.Replace(L"\\", L"/");
+		txt.AppendFormat("file://%s", T2Utf(pwszFileName).get());
+	}
+}
+
 CMStringA WhatsAppProto::GetMessageText(const Wa__Message *pMessage)
 {
 	CMStringA szMessageText;
@@ -439,6 +478,15 @@ CMStringA WhatsAppProto::GetMessageText(const Wa__Message *pMessage)
 			if (pExt->text)
 				szMessageText.Append(pExt->text);
 		}
+		else if (auto *pAudio = pMessage->audiomessage) {
+			GetMessageContent(szMessageText, "audio", pAudio->url, pAudio->directpath, pAudio->mimetype, pAudio->mediakey);
+		}
+		else if (auto *pVideo = pMessage->videomessage) {
+			GetMessageContent(szMessageText, "video", pVideo->url, pVideo->directpath, pVideo->mimetype, pVideo->mediakey, pVideo->caption);
+		}
+		else if (auto *pImage = pMessage->imagemessage) {
+			GetMessageContent(szMessageText, "image", pImage->url, pImage->directpath, pImage->mimetype, pImage->mediakey, pImage->caption);
+		}
 		else if (mir_strlen(pMessage->conversation))
 			szMessageText = pMessage->conversation;
 	}
-- 
cgit v1.2.3