core: debug chat and agent locks, update simplexmq (#1243)

* core: debug chat and agent locks, update simplexmq

* add connId

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* update lock strings

* fix encoding test

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
This commit is contained in:
Evgeny Poberezkin
2022-10-22 21:22:44 +01:00
committed by GitHub
parent d0a0a0461f
commit 7f544da6cf
8 changed files with 73 additions and 52 deletions

View File

@@ -7,7 +7,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: f97c1a771299e7e6b1e5af77db0ada007d6aa568
tag: 19aef52135b288dc92c4a2ec6d0be4b8283649ab
source-repository-package
type: git

View File

@@ -34,7 +34,7 @@ dependencies:
- process == 1.6.*
- random >= 1.1 && < 1.3
- simple-logger == 0.1.*
- simplexmq >= 3.0
- simplexmq >= 3.3
- socks == 0.6.*
- sqlcipher-simple == 0.4.*
- stm == 2.5.*

View File

@@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."f97c1a771299e7e6b1e5af77db0ada007d6aa568" = "1p45yp10az0hpqlg4zf4sba2ryqxhhic3zr5bylzqkxzhsy44zg4";
"https://github.com/simplex-chat/simplexmq.git"."19aef52135b288dc92c4a2ec6d0be4b8283649ab" = "0xkhm9hplm55ms34ln7la8zm59ailsxziqbaj5y79z9lafysl15j";
"https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd";
"https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0";
"https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp";

View File

@@ -58,6 +58,7 @@ import Simplex.Chat.Types
import Simplex.Chat.Util (safeDecodeUtf8)
import Simplex.Messaging.Agent as Agent
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), AgentDatabase (..), InitialAgentServers (..), createAgentStore, defaultAgentConfig)
import Simplex.Messaging.Agent.Lock
import Simplex.Messaging.Agent.Protocol
import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (dbNew), execSQL)
import Simplex.Messaging.Client (defaultNetworkConfig)
@@ -146,7 +147,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen
inputQ <- newTBQueueIO tbqSize
outputQ <- newTBQueueIO tbqSize
notifyQ <- newTBQueueIO tbqSize
chatLock <- newTMVarIO ()
chatLock <- newEmptyTMVarIO
sndFiles <- newTVarIO M.empty
rcvFiles <- newTVarIO M.empty
currentCalls <- atomically TM.empty
@@ -213,12 +214,6 @@ stopChatController ChatController {smpAgent, agentAsync = s, expireCIs} = do
writeTVar expireCIs False
writeTVar s Nothing
withLock :: MonadUnliftIO m => TMVar () -> m a -> m a
withLock lock =
E.bracket_
(void . atomically $ takeTMVar lock)
(atomically $ putTMVar lock ())
execChatCommand :: (MonadUnliftIO m, MonadReader ChatController m) => ByteString -> m ChatResponse
execChatCommand s = case parseChatCommand s of
Left e -> pure $ chatCmdError e
@@ -281,7 +276,7 @@ processChatCommand = \case
CTContactRequest -> pure $ chatCmdError "not implemented"
CTContactConnection -> pure $ chatCmdError "not supported"
APIGetChatItems _pagination -> pure $ chatCmdError "not implemented"
APISendMessage (ChatRef cType chatId) (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock $ case cType of
APISendMessage (ChatRef cType chatId) (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock "sendMessage" $ case cType of
CTDirect -> do
ct@Contact {localDisplayName = c} <- withStore $ \db -> getContact db userId chatId
(fileInvitation_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer ct
@@ -397,7 +392,7 @@ processChatCommand = \case
unzipMaybe3 :: Maybe (a, b, c) -> (Maybe a, Maybe b, Maybe c)
unzipMaybe3 (Just (a, b, c)) = (Just a, Just b, Just c)
unzipMaybe3 _ = (Nothing, Nothing, Nothing)
APIUpdateChatItem (ChatRef cType chatId) itemId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of
APIUpdateChatItem (ChatRef cType chatId) itemId mc -> withUser $ \user@User {userId} -> withChatLock "updateChatItem" $ case cType of
CTDirect -> do
(ct@Contact {contactId, localDisplayName = c}, ci) <- withStore $ \db -> (,) <$> getContact db userId chatId <*> getDirectChatItem db userId chatId itemId
case ci of
@@ -426,7 +421,7 @@ processChatCommand = \case
CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate
CTContactRequest -> pure $ chatCmdError "not supported"
CTContactConnection -> pure $ chatCmdError "not supported"
APIDeleteChatItem (ChatRef cType chatId) itemId mode -> withUser $ \user@User {userId} -> withChatLock $ case cType of
APIDeleteChatItem (ChatRef cType chatId) itemId mode -> withUser $ \user@User {userId} -> withChatLock "deleteChatItem" $ case cType of
CTDirect -> do
(ct@Contact {localDisplayName = c}, CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}, file}) <- withStore $ \db -> (,) <$> getContact db userId chatId <*> getDirectChatItem db userId chatId itemId
case (mode, msgDir, itemSharedMsgId) of
@@ -466,7 +461,7 @@ processChatCommand = \case
forM_ file $ \CIFile {fileId, filePath, fileStatus} -> do
let fileInfo = CIFileInfo {fileId, fileStatus = Just $ AFS msgDirection fileStatus, filePath}
deleteFile user fileInfo
APIChatRead (ChatRef cType chatId) fromToIds -> withChatLock $ case cType of
APIChatRead (ChatRef cType chatId) fromToIds -> case cType of
CTDirect -> withStore' (\db -> updateDirectChatItemsRead db chatId fromToIds) $> CRCmdOk
CTGroup -> withStore' (\db -> updateGroupChatItemsRead db chatId fromToIds) $> CRCmdOk
CTContactRequest -> pure $ chatCmdError "not supported"
@@ -488,7 +483,7 @@ processChatCommand = \case
ct@Contact {localDisplayName} <- withStore $ \db -> getContact db userId chatId
filesInfo <- withStore' $ \db -> getContactFileInfo db user ct
conns <- withStore $ \db -> getContactConnections db userId ct
withChatLock . procCmd $ do
withChatLock "deleteChat direct" . procCmd $ do
forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo
forM_ conns $ \conn -> deleteAgentConnectionAsync user conn `catchError` \_ -> pure ()
-- functions below are called in separate transactions to prevent crashes on android
@@ -497,7 +492,7 @@ processChatCommand = \case
withStore' $ \db -> deleteContact db userId ct
unsetActive $ ActiveC localDisplayName
pure $ CRContactDeleted ct
CTContactConnection -> withChatLock . procCmd $ do
CTContactConnection -> withChatLock "deleteChat contactConnection" . procCmd $ do
conn@PendingContactConnection {pccConnId, pccAgentConnId} <- withStore $ \db -> getPendingContactConnection db userId chatId
deleteAgentConnectionAsync' user pccConnId pccAgentConnId
withStore' $ \db -> deletePendingContactConnection db userId chatId
@@ -507,7 +502,7 @@ processChatCommand = \case
let canDelete = memberRole (membership :: GroupMember) == GROwner || not (memberCurrent membership)
unless canDelete $ throwChatError CEGroupUserRole
filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo
withChatLock . procCmd $ do
withChatLock "deleteChat group" . procCmd $ do
forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo
when (memberActive membership) . void $ sendGroupMessage gInfo members XGrpDel
deleteGroupLink' user gInfo `catchError` \_ -> pure ()
@@ -546,14 +541,14 @@ processChatCommand = \case
pure $ CRChatCleared (AChatInfo SCTGroup (GroupChat gInfo'))
CTContactConnection -> pure $ chatCmdError "not supported"
CTContactRequest -> pure $ chatCmdError "not supported"
APIAcceptContact connReqId -> withUser $ \user@User {userId} -> withChatLock $ do
APIAcceptContact connReqId -> withUser $ \user@User {userId} -> withChatLock "acceptContact" $ do
cReq <- withStore $ \db -> getContactRequest db userId connReqId
-- [incognito] generate profile to send, create connection with incognito profile
incognito <- readTVarIO =<< asks incognitoMode
incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing
ct <- acceptContactRequest user cReq incognitoProfile
pure $ CRAcceptingContactRequest ct
APIRejectContact connReqId -> withUser $ \User {userId} -> withChatLock $ do
APIRejectContact connReqId -> withUser $ \User {userId} -> withChatLock "rejectContact" $ do
cReq@UserContactRequest {agentContactConnId = AgentConnId connId, agentInvitationId = AgentInvId invId} <-
withStore $ \db ->
getContactRequest db userId connReqId
@@ -564,7 +559,7 @@ processChatCommand = \case
-- party initiating call
ct <- withStore $ \db -> getContact db userId contactId
calls <- asks currentCalls
withChatLock $ do
withChatLock "sendCallInvitation" $ do
callId <- CallId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
dhKeyPair <- if encryptedCall callType then Just <$> liftIO C.generateKeyPair' else pure Nothing
let invitation = CallInvitation {callType, callDhPubKey = fst <$> dhKeyPair}
@@ -667,14 +662,14 @@ processChatCommand = \case
connEntity <- withStore (\db -> Just <$> getConnectionEntity db user (AgentConnId ntfConnId)) `catchError` \_ -> pure Nothing
pure CRNtfMessages {connEntity, msgTs = msgTs', ntfMessages}
GetUserSMPServers -> CRUserSMPServers <$> withUser (\user -> withStore' (`getSMPServers` user))
SetUserSMPServers smpServers -> withUser $ \user -> withChatLock $ do
SetUserSMPServers smpServers -> withUser $ \user -> withChatLock "setUserSMPServers" $ do
withStore $ \db -> overwriteSMPServers db user smpServers
ChatConfig {defaultServers = InitialAgentServers {smp = defaultSMPServers}} <- asks config
withAgent $ \a -> setSMPServers a (fromMaybe defaultSMPServers (nonEmpty smpServers))
pure CRCmdOk
APISetChatItemTTL newTTL_ -> withUser' $ \user ->
checkStoreNotChanged $
withChatLock $ do
withChatLock "setChatItemTTL" $ do
case newTTL_ of
Nothing -> do
withStore' $ \db -> setChatItemTTL db user newTTL_
@@ -731,7 +726,7 @@ processChatCommand = \case
processChatCommand $ APIGroupMemberInfo gId mId
ChatHelp section -> pure $ CRChatHelp section
Welcome -> withUser $ pure . CRWelcome
AddContact -> withUser $ \User {userId} -> withChatLock . procCmd $ do
AddContact -> withUser $ \User {userId} -> withChatLock "addContact" . procCmd $ do
-- [incognito] generate profile for connection
incognito <- readTVarIO =<< asks incognitoMode
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
@@ -739,7 +734,7 @@ processChatCommand = \case
conn <- withStore' $ \db -> createDirectConnection db userId connId cReq ConnNew incognitoProfile
toView $ CRNewContactConnection conn
pure $ CRInvitation cReq
Connect (Just (ACR SCMInvitation cReq)) -> withUser $ \User {userId, profile} -> withChatLock . procCmd $ do
Connect (Just (ACR SCMInvitation cReq)) -> withUser $ \User {userId, profile} -> withChatLock "connect" . procCmd $ do
-- [incognito] generate profile to send
incognito <- readTVarIO =<< asks incognitoMode
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
@@ -762,11 +757,11 @@ processChatCommand = \case
contactId <- withStore $ \db -> getContactIdByName db user cName
processChatCommand $ APIClearChat (ChatRef CTDirect contactId)
ListContacts -> withUser $ \user -> CRContactsList <$> withStore' (`getUserContacts` user)
CreateMyAddress -> withUser $ \User {userId} -> withChatLock . procCmd $ do
CreateMyAddress -> withUser $ \User {userId} -> withChatLock "createMyAddress" . procCmd $ do
(connId, cReq) <- withAgent $ \a -> createConnection a True SCMContact
withStore $ \db -> createUserContactLink db userId connId cReq
pure $ CRUserContactLinkCreated cReq
DeleteMyAddress -> withUser $ \user -> withChatLock $ do
DeleteMyAddress -> withUser $ \user -> withChatLock "deleteMyAddress" $ do
conns <- withStore (`getUserAddressConnections` user)
procCmd $ do
forM_ conns $ \conn -> deleteAgentConnectionAsync user conn `catchError` \_ -> pure ()
@@ -788,7 +783,7 @@ processChatCommand = \case
processChatCommand . APISendMessage chatRef $ ComposedMessage Nothing Nothing mc
SendMessageBroadcast msg -> withUser $ \user -> do
contacts <- withStore' (`getUserContacts` user)
withChatLock . procCmd $ do
withChatLock "sendMessageBroadcast" . procCmd $ do
let mc = MCText $ safeDecodeUtf8 msg
cts = filter isReady contacts
forM_ cts $ \ct ->
@@ -817,7 +812,7 @@ processChatCommand = \case
gVar <- asks idsDrg
groupInfo <- withStore (\db -> createNewGroup db gVar user gProfile)
pure $ CRGroupCreated groupInfo
APIAddMember groupId contactId memRole -> withUser $ \user@User {userId} -> withChatLock $ do
APIAddMember groupId contactId memRole -> withUser $ \user@User {userId} -> withChatLock "addMember" $ do
-- TODO for large groups: no need to load all members to determine if contact is a member
(group, contact) <- withStore $ \db -> (,) <$> getGroup db user groupId <*> getContact db userId contactId
let Group gInfo@GroupInfo {membership} members = group
@@ -846,7 +841,7 @@ processChatCommand = \case
| otherwise -> throwChatError $ CEGroupDuplicateMember cName
APIJoinGroup groupId -> withUser $ \user@User {userId} -> do
ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} <- withStore $ \db -> getGroupInvitation db user groupId
withChatLock . procCmd $ do
withChatLock "joinGroup" . procCmd $ do
agentConnId <- withAgent $ \a -> joinConnection a True connRequest . directMessage $ XGrpAcpt (memberId (membership :: GroupMember))
withStore' $ \db -> do
createMemberConnection db userId fromMember agentConnId
@@ -875,7 +870,7 @@ processChatCommand = \case
GroupMember {memberRole = userRole} = membership
canChangeRole = userRole >= GRAdmin && userRole >= mRole && userRole >= memRole && memberCurrent membership
unless canChangeRole $ throwChatError CEGroupUserRole
withChatLock . procCmd $ do
withChatLock "memberRole" . procCmd $ do
unless (mRole == memRole) $ do
withStore' $ \db -> updateGroupMemberRole db user m memRole
case mStatus of
@@ -896,7 +891,7 @@ processChatCommand = \case
let userRole = memberRole (membership :: GroupMember)
canRemove = userRole >= GRAdmin && userRole >= mRole && memberCurrent membership
unless canRemove $ throwChatError CEGroupUserRole
withChatLock . procCmd $ do
withChatLock "removeMember" . procCmd $ do
case mStatus of
GSMemInvited -> do
deleteMemberConnection user m
@@ -910,7 +905,7 @@ processChatCommand = \case
pure $ CRUserDeletedMember gInfo m {memberStatus = GSMemRemoved}
APILeaveGroup groupId -> withUser $ \user@User {userId} -> do
Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db user groupId
withChatLock . procCmd $ do
withChatLock "leaveGroup" . procCmd $ do
msg <- sendGroupMessage gInfo members XGrpLeave
ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent SGEUserLeft) Nothing Nothing
toView . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci
@@ -960,7 +955,7 @@ processChatCommand = \case
UpdateGroupProfile gName profile -> withUser $ \user -> do
groupId <- withStore $ \db -> getGroupIdByName db user gName
processChatCommand $ APIUpdateGroupProfile groupId profile
APICreateGroupLink groupId -> withUser $ \user -> withChatLock $ do
APICreateGroupLink groupId -> withUser $ \user -> withChatLock "createGroupLink" $ do
gInfo@GroupInfo {membership = membership@GroupMember {memberRole = userRole}} <- withStore $ \db -> getGroupInfo db user groupId
when (userRole < GRAdmin) $ throwChatError CEGroupUserRole
when (memberStatus membership == GSMemInvited) $ throwChatError (CEGroupNotJoined gInfo)
@@ -968,7 +963,7 @@ processChatCommand = \case
(connId, cReq) <- withAgent $ \a -> createConnection a True SCMContact
withStore $ \db -> createGroupLink db user gInfo connId cReq
pure $ CRGroupLinkCreated gInfo cReq
APIDeleteGroupLink groupId -> withUser $ \user -> withChatLock $ do
APIDeleteGroupLink groupId -> withUser $ \user -> withChatLock "deleteGroupLink" $ do
gInfo <- withStore $ \db -> getGroupInfo db user groupId
deleteGroupLink' user gInfo
pure $ CRGroupLinkDeleted gInfo
@@ -1007,7 +1002,7 @@ processChatCommand = \case
ForwardFile chatName fileId -> forwardFile chatName fileId SendFile
ForwardImage chatName fileId -> forwardFile chatName fileId SendImage
ReceiveFile fileId rcvInline_ filePath_ -> withUser $ \user ->
withChatLock . procCmd $ do
withChatLock "receiveFile" . procCmd $ do
ft <- withStore $ \db -> getRcvFileTransfer db user fileId
(CRRcvFileAccepted <$> acceptFileReceive user ft rcvInline_ filePath_) `catchError` processError ft
where
@@ -1017,7 +1012,7 @@ processChatCommand = \case
ChatErrorAgent (CONN DUPLICATE) -> pure $ CRRcvFileAcceptedSndCancelled ft
e -> throwError e
CancelFile fileId -> withUser $ \user@User {userId} ->
withChatLock . procCmd $
withChatLock "cancelFile" . procCmd $
withStore (\db -> getFileTransfer db user fileId) >>= \case
FTSnd ftm@FileTransferMeta {cancelled} fts -> do
unless cancelled $ do
@@ -1047,8 +1042,12 @@ processChatCommand = \case
updateProfile user p
QuitChat -> liftIO exitSuccess
ShowVersion -> pure $ CRVersionInfo versionNumber
DebugLocks -> do
chatLockName <- atomically . tryReadTMVar =<< asks chatLock
agentLocks <- withAgent debugAgentLocks
pure CRDebugLocks {chatLockName, agentLocks}
where
withChatLock action = asks chatLock >>= (`withLock` action)
withChatLock name action = asks chatLock >>= \l -> withLock l name action
-- below code would make command responses asynchronous where they can be slow
-- in View.hs `r'` should be defined as `id` in this case
-- procCmd :: m ChatResponse -> m ChatResponse
@@ -1056,7 +1055,7 @@ processChatCommand = \case
-- ChatController {chatLock = l, smpAgent = a, outputQ = q, idsDrg = gVar} <- ask
-- corrId <- liftIO $ SMP.CorrId <$> randomBytes gVar 8
-- void . forkIO $
-- withAgentLock a . withLock l $
-- withAgentLock a . withLock l name $
-- (atomically . writeTBQueue q) . (Just corrId,) =<< (action `catchError` (pure . CRChatError))
-- pure $ CRCmdAccepted corrId
-- use function below to make commands "synchronous"
@@ -1082,7 +1081,7 @@ processChatCommand = \case
CTGroup -> withStore $ \db -> getGroupChatItemIdByText db user cId (Just localDisplayName) (safeDecodeUtf8 msg)
_ -> throwChatError $ CECommandError "not supported"
connectViaContact :: UserId -> ConnectionRequestUri 'CMContact -> Profile -> m ChatResponse
connectViaContact userId cReq profile = withChatLock $ do
connectViaContact userId cReq profile = withChatLock "connectViaContact" $ do
let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq
withStore' (\db -> getConnReqContactXContactId db userId cReqHash) >>= \case
(Just contact, _) -> pure $ CRContactAlreadyExists contact
@@ -1128,7 +1127,7 @@ processChatCommand = \case
contacts <-
filter (\ct -> isReady ct && not (contactConnIncognito ct))
<$> withStore' (`getUserContacts` user)
withChatLock . procCmd $ do
withChatLock "updateProfile" . procCmd $ do
forM_ contacts $ \ct ->
void (sendDirectContactMessage ct $ XInfo p') `catchError` (toView . CRChatError)
pure $ CRUserProfileUpdated (fromLocalProfile p) p'
@@ -1140,7 +1139,7 @@ processChatCommand = \case
withCurrentCall ctId action = withUser $ \user@User {userId} -> do
ct <- withStore $ \db -> getContact db userId ctId
calls <- asks currentCalls
withChatLock $
withChatLock "currentCall" $
atomically (TM.lookup ctId calls) >>= \case
Nothing -> throwChatError CENoCurrentCall
Just call@Call {contactId}
@@ -1380,8 +1379,12 @@ agentSubscriber = do
forever $ do
(corrId, connId, msg) <- atomically $ readTBQueue q
u <- readTVarIO =<< asks currentUser
withLock l . void . runExceptT $
let name = "agentSubscriber connId=" <> str connId <> " corrId=" <> str corrId <> " msg=" <> str (aCommandTag msg)
withLock l name . void . runExceptT $
processAgentMessage u corrId connId msg `catchError` (toView . CRChatError)
where
str :: StrEncoding a => a -> String
str = B.unpack . strEncode
type AgentBatchSubscribe m = AgentClient -> [ConnId] -> ExceptT AgentErrorType m (Map ConnId (Either AgentErrorType ()))
@@ -1472,7 +1475,7 @@ subscribeUserConnections agentBatchSubscribe user = do
void . forkIO $ do
threadDelay 1000000
l <- asks chatLock
when (fileStatus == FSConnected) . unlessM (isFileActive fileId sndFiles) . withLock l $
when (fileStatus == FSConnected) . unlessM (isFileActive fileId sndFiles) . withLock l "subscribe sendFileChunk" $
sendFileChunk user ft
rcvFileSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId RcvFileTransfer -> m ()
rcvFileSubsToView rs = mapM_ (toView . uncurry CRRcvFileSubError) . filterErrors . resultsFor rs
@@ -1633,6 +1636,7 @@ processAgentMessage (Just user@User {userId, profile}) corrId agentConnId agentM
MSG msgMeta _msgFlags msgBody -> do
cmdId <- createAckCmd conn
msg@RcvMessage {chatMsgEvent = ACME _ event} <- saveRcvMSG conn (ConnectionId connId) msgMeta msgBody cmdId
updateChatLock "directMessage" event
withAckMessage agentConnId cmdId msgMeta $
case event of
XMsgNew mc -> newContentMessage ct mc msg msgMeta
@@ -1832,6 +1836,7 @@ processAgentMessage (Just user@User {userId, profile}) corrId agentConnId agentM
MSG msgMeta _msgFlags msgBody -> do
cmdId <- createAckCmd conn
msg@RcvMessage {chatMsgEvent = ACME _ event} <- saveRcvMSG conn (GroupId groupId) msgMeta msgBody cmdId
updateChatLock "groupMessage" event
withAckMessage agentConnId cmdId msgMeta $
case event of
XMsgNew mc -> newGroupContentMessage gInfo m mc msg msgMeta
@@ -2018,6 +2023,13 @@ processAgentMessage (Just user@User {userId, profile}) corrId agentConnId agentM
showToast (localDisplayName <> "> ") "wants to connect to you"
_ -> pure ()
updateChatLock :: MsgEncodingI e => String -> ChatMsgEvent e -> m ()
updateChatLock name event = do
l <- asks chatLock
atomically $ tryReadTMVar l >>= mapM_ (swapTMVar l . (<> s))
where
s = " " <> name <> "=" <> B.unpack (strEncode $ toCMEventTag event)
withCompletedCommand :: Connection -> ACommand 'Agent -> (CommandData -> m ()) -> m ()
withCompletedCommand Connection {connId} agentMsg action = do
let agentMsgTag = aCommandTag agentMsg
@@ -3187,7 +3199,8 @@ chatCommandP =
("/profile" <|> "/p") $> ShowProfile,
"/incognito " *> (SetIncognito <$> onOffP),
("/quit" <|> "/q" <|> "/exit") $> QuitChat,
("/version" <|> "/v") $> ShowVersion
("/version" <|> "/v") $> ShowVersion,
"/debug locks" $> DebugLocks
]
where
imagePrefix = (<>) <$> "data:" <*> ("image/png;base64," <|> "image/jpg;base64,")

View File

@@ -39,7 +39,9 @@ import Simplex.Chat.Protocol
import Simplex.Chat.Store (AutoAccept, StoreError, UserContactLink)
import Simplex.Chat.Types
import Simplex.Messaging.Agent (AgentClient)
import Simplex.Messaging.Agent.Client (AgentLocks)
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, InitialAgentServers, NetworkConfig)
import Simplex.Messaging.Agent.Lock
import Simplex.Messaging.Agent.Protocol
import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore)
import qualified Simplex.Messaging.Crypto as C
@@ -108,7 +110,7 @@ data ChatController = ChatController
outputQ :: TBQueue (Maybe CorrId, ChatResponse),
notifyQ :: TBQueue Notification,
sendNotification :: Notification -> IO (),
chatLock :: TMVar (),
chatLock :: Lock,
sndFiles :: TVar (Map Int64 Handle),
rcvFiles :: TVar (Map Int64 Handle),
currentCalls :: TMap ContactId Call,
@@ -241,6 +243,7 @@ data ChatCommand
| UpdateProfileImage (Maybe ImageData)
| QuitChat
| ShowVersion
| DebugLocks
deriving (Show)
data ChatResponse
@@ -363,6 +366,7 @@ data ChatResponse
| CRNewContactConnection {connection :: PendingContactConnection}
| CRContactConnectionDeleted {connection :: PendingContactConnection}
| CRSQLResult {rows :: [Text]}
| CRDebugLocks {chatLockName :: Maybe String, agentLocks :: AgentLocks}
| CRMessageError {severity :: Text, errorMessage :: Text}
| CRChatCmdError {chatError :: ChatError}
| CRChatError {chatError :: ChatError}

View File

@@ -193,6 +193,10 @@ responseToView testView = \case
CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)]
CRNtfMessages {} -> []
CRSQLResult rows -> map plain rows
CRDebugLocks {chatLockName, agentLocks} ->
[ maybe "no chat lock" (("chat lock: " <>) . plain) chatLockName,
plain $ "agent locks: " <> LB.unpack (J.encode agentLocks)
]
CRMessageError prefix err -> [plain prefix <> ": " <> plain err]
CRChatError e -> viewChatError e
where

View File

@@ -49,7 +49,7 @@ extra-deps:
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
# - ../simplexmq
- github: simplex-chat/simplexmq
commit: f97c1a771299e7e6b1e5af77db0ada007d6aa568
commit: 19aef52135b288dc92c4a2ec6d0be4b8283649ab
# - ../direct-sqlcipher
- github: simplex-chat/direct-sqlcipher
commit: 34309410eb2069b029b8fc1872deb1e0db123294

View File

@@ -47,7 +47,7 @@ testDhPubKey :: C.PublicKeyX448
testDhPubKey = "MEIwBQYDK2VvAzkAmKuSYeQ/m0SixPDS8Wq8VBaTS1cW+Lp0n0h4Diu+kUpR+qXx4SDJ32YGEFoGFGSbGPry5Ychr6U="
testE2ERatchetParams :: E2ERatchetParamsUri 'C.X448
testE2ERatchetParams = E2ERatchetParamsUri e2eEncryptVRange testDhPubKey testDhPubKey
testE2ERatchetParams = E2ERatchetParamsUri supportedE2EEncryptVRange testDhPubKey testDhPubKey
testConnReq :: ConnectionRequestUri 'CMInvitation
testConnReq = CRInvitationUri connReqData testE2ERatchetParams
@@ -155,7 +155,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
"{\"event\":\"x.msg.deleted\",\"params\":{}}"
#==# XMsgDeleted
it "x.file" $
"{\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
"{\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
#==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Just testConnReq, fileInline = Nothing}
it "x.file without file invitation" $
"{\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
@@ -164,7 +164,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
"{\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}"
#==# XFileAcpt "photo.jpg"
it "x.file.acpt.inv" $
"{\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}"
"{\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}"
#==# XFileAcptInv (SharedMsgId "\1\2\3\4") (Just testConnReq) "photo.jpg"
it "x.file.acpt.inv" $
"{\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\"}}"
@@ -191,7 +191,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
"{\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
==# XContact testProfile Nothing
it "x.grp.inv" $
"{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\"},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}"
"{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\"},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}"
#==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile}
it "x.grp.acpt without incognito profile" $
"{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}"
@@ -203,10 +203,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
"{\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}}"
#==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile}
it "x.grp.mem.inv" $
"{\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}"
"{\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}"
#==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = testConnReq}
it "x.grp.mem.fwd" $
"{\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}}"
"{\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}}"
#==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = testConnReq}
it "x.grp.mem.info" $
"{\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"