core: use concurrency-safe agent async api (#7035)

* core: use concurrency-safe agent async api

* simplexmq

* simplexmq

* combine transactions (build fails, names need to be merged first)

* fix build

* update simplexmq

* remove uncurry

* remove createAgentConnectionAsync

* remove prepareJoinMemberContact

* mapM

* fix tests

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
This commit is contained in:
Evgeny
2026-07-04 12:16:45 +01:00
committed by GitHub
parent 6bb1da9e8d
commit 712effe34f
5 changed files with 73 additions and 44 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: 836254a4c6bd5e17b40acbcaf77a83802d44919e
tag: a45d764eaa82fb4f26f8dfeb537c1464c8dad972
source-repository-package
type: git
+1 -1
View File
@@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."836254a4c6bd5e17b40acbcaf77a83802d44919e" = "1g4385sv7i1h63yk3ch2kw8hprak1dzvr34v9mn6xz0fy10g8xlg";
"https://github.com/simplex-chat/simplexmq.git"."a45d764eaa82fb4f26f8dfeb537c1464c8dad972" = "1f76904lwhxz9jkix0viaj715a88dlhr0x9v7jz79javcz57zcxp";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
+45 -25
View File
@@ -929,13 +929,15 @@ acceptContactRequestAsync
profileToSend <- presentUserBadge user incognitoProfile $ userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True
cxt <- chatStoreCxt
let chatV = vr cxt `peerConnChatVersion` cReqChatVRange
(cmdId, acId) <- agentAcceptContactAsync user True cReqInvId (XInfo profileToSend) subMode cReqPQSup chatV
(cmdId, acId) <- prepareAgentAccept user True cReqInvId cReqPQSup
currentTs <- liftIO getCurrentTime
withStore $ \db -> do
ct' <- withStore $ \db -> do
forM_ xContactId $ \xcId -> liftIO $ setContactAcceptedXContactId db ct xcId
Connection {connId} <- liftIO $ createAcceptedContactConn db user (Just uclId) contactId acId chatV cReqChatVRange cReqPQSup incognitoProfile subMode currentTs
liftIO $ setCommandConnId db user cmdId connId
getContact db cxt user contactId
agentAcceptContactAsync cmdId acId True cReqInvId (XInfo profileToSend) cReqPQSup chatV subMode
pure ct'
acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> Maybe MemberKey -> Maybe GroupMember -> CM GroupMember
acceptGroupJoinRequestAsync
@@ -983,10 +985,12 @@ acceptGroupJoinRequestAsync
}
subMode <- chatReadVar subscriptionMode
let chatV = vr cxt `peerConnChatVersion` cReqChatVRange
connIds <- agentAcceptContactAsync user True cReqInvId msg subMode PQSupportOff chatV
withStore $ \db -> do
liftIO $ createJoiningMemberConnection db user uclId connIds chatV cReqChatVRange groupMemberId subMode
(cmdId, acId) <- prepareAgentAccept user True cReqInvId PQSupportOff
m <- withStore $ \db -> do
liftIO $ createJoiningMemberConnection db user uclId (cmdId, acId) chatV cReqChatVRange groupMemberId subMode
getGroupMemberById db cxt user groupMemberId
agentAcceptContactAsync cmdId acId True cReqInvId msg PQSupportOff chatV subMode
pure m
acceptGroupJoinSendRejectAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> GroupRejectionReason -> CM GroupMember
acceptGroupJoinSendRejectAsync
@@ -1013,10 +1017,12 @@ acceptGroupJoinSendRejectAsync
}
subMode <- chatReadVar subscriptionMode
let chatV = vr cxt `peerConnChatVersion` cReqChatVRange
connIds <- agentAcceptContactAsync user False cReqInvId msg subMode PQSupportOff chatV
withStore $ \db -> do
liftIO $ createJoiningMemberConnection db user uclId connIds chatV cReqChatVRange groupMemberId subMode
(cmdId, acId) <- prepareAgentAccept user False cReqInvId PQSupportOff
m <- withStore $ \db -> do
liftIO $ createJoiningMemberConnection db user uclId (cmdId, acId) chatV cReqChatVRange groupMemberId subMode
getGroupMemberById db cxt user groupMemberId
agentAcceptContactAsync cmdId acId False cReqInvId msg PQSupportOff chatV subMode
pure m
acceptBusinessJoinRequestAsync :: User -> Int64 -> GroupInfo -> GroupMember -> UserContactRequest -> CM (GroupInfo, GroupMember)
acceptBusinessJoinRequestAsync
@@ -1045,10 +1051,11 @@ acceptBusinessJoinRequestAsync
}
subMode <- chatReadVar subscriptionMode
let chatV = vr cxt `peerConnChatVersion` cReqChatVRange
connIds <- agentAcceptContactAsync user True cReqInvId msg subMode PQSupportOff chatV
(cmdId, acId) <- prepareAgentAccept user True cReqInvId PQSupportOff
withStore' $ \db -> do
forM_ xContactId $ \xcId -> setBusinessChatAcceptedXContactId db gInfo xcId
createJoiningMemberConnection db user uclId connIds chatV cReqChatVRange groupMemberId subMode
createJoiningMemberConnection db user uclId (cmdId, acId) chatV cReqChatVRange groupMemberId subMode
agentAcceptContactAsync cmdId acId True cReqInvId msg PQSupportOff chatV subMode
let cd = CDGroupSnd gInfo Nothing
-- TODO [short links] move to profileContactRequest?
createInternalChatItem user cd (CISndGroupE2EEInfo $ e2eInfoGroup gInfo) Nothing
@@ -1071,12 +1078,14 @@ acceptRelayJoinRequestAsync
subMode <- chatReadVar subscriptionMode
cxt <- chatStoreCxt
let chatV = vr cxt `peerConnChatVersion` cReqChatVRange
connIds <- agentAcceptContactAsync user True cReqInvId msg subMode PQSupportOff chatV
withStore $ \db -> do
liftIO $ createJoiningMemberConnection db user uclId connIds chatV cReqChatVRange groupMemberId subMode
(cmdId, acId) <- prepareAgentAccept user True cReqInvId PQSupportOff
r <- withStore $ \db -> do
liftIO $ createJoiningMemberConnection db user uclId (cmdId, acId) chatV cReqChatVRange groupMemberId subMode
gInfo' <- liftIO $ updateRelayOwnStatusFromTo db gInfo RSInvited RSAccepted
ownerMember' <- getGroupMemberById db cxt user groupMemberId
pure (gInfo', ownerMember')
agentAcceptContactAsync cmdId acId True cReqInvId msg PQSupportOff chatV subMode
pure r
rejectRelayInvitationAsync
:: User
@@ -1096,9 +1105,10 @@ rejectRelayInvitationAsync user uclId cxt groupRelayInv invId reqChatVRange init
subMode <- chatReadVar subscriptionMode
chatVR <- chatVersionRange
let chatV = chatVR `peerConnChatVersion` reqChatVRange
connIds <- agentAcceptContactAsync user False invId msg subMode PQSupportOff chatV
(cmdId, acId) <- prepareAgentAccept user False invId PQSupportOff
withStore' $ \db ->
createJoiningMemberConnection db user uclId connIds chatV reqChatVRange groupMemberId subMode
createJoiningMemberConnection db user uclId (cmdId, acId) chatV reqChatVRange groupMemberId subMode
agentAcceptContactAsync cmdId acId False invId msg PQSupportOff chatV subMode
businessGroupProfile :: Profile -> GroupPreferences -> GroupProfile
businessGroupProfile Profile {displayName, fullName, shortDescr, image} groupPreferences =
@@ -2752,18 +2762,24 @@ msgContentHasLink mc ft_ = case msgContentTag mc of
MCLink_ -> True
_ -> maybe False hasLinks ft_
createAgentConnectionAsync :: ConnectionModeI c => User -> CommandFunction -> Bool -> SConnectionMode c -> SubscriptionMode -> CM (CommandId, ConnId)
createAgentConnectionAsync user cmdFunction enableNtfs cMode subMode = do
prepareAgentCreation :: ConnectionModeI c => User -> CommandFunction -> Bool -> SConnectionMode c -> CM (CommandId, ConnId)
prepareAgentCreation user cmdFunction enableNtfs cMode = do
cmdId <- withStore' $ \db -> createCommand db user Nothing cmdFunction
connId <- withAgent $ \a -> createConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cMode IKPQOff subMode
connId <- withAgent $ \a -> prepareConnectionToCreate a (aUserId user) enableNtfs cMode PQSupportOff
pure (cmdId, connId)
joinAgentConnectionAsync :: User -> Maybe Connection -> Bool -> ConnectionRequestUri c -> ConnInfo -> SubscriptionMode -> CM (CommandId, ConnId)
joinAgentConnectionAsync user conn_ enableNtfs cReqUri cInfo subMode = do
prepareAgentJoin :: User -> Maybe Connection -> Bool -> ConnectionRequestUri c -> CM (CommandId, ConnId)
prepareAgentJoin user conn_ enableNtfs cReqUri = do
cmdId <- withStore' $ \db -> createCommand db user (dbConnId <$> conn_) CFJoinConn
connId <- withAgent $ \a -> joinConnectionAsync a (aUserId user) (aCorrId cmdId) (aConnId <$> conn_) enableNtfs cReqUri cInfo PQSupportOff subMode
connId <- case conn_ of
Just conn -> pure $ aConnId conn
Nothing -> withAgent $ \a -> prepareConnectionToJoin a (aUserId user) enableNtfs cReqUri PQSupportOff
pure (cmdId, connId)
joinAgentConnectionAsync :: ConnectionModeI c => CommandId -> Bool -> ConnId -> Bool -> ConnectionRequestUri c -> ConnInfo -> SubscriptionMode -> CM ()
joinAgentConnectionAsync cmdId updateConn connId enableNtfs cReqUri cInfo subMode =
withAgent $ \a -> joinConnectionAsync a (aCorrId cmdId) updateConn connId enableNtfs cReqUri cInfo PQSupportOff subMode
allowAgentConnectionAsync :: MsgEncodingI e => User -> Connection -> ConfirmationId -> ChatMsgEvent e -> CM ()
allowAgentConnectionAsync user conn@Connection {connId, pqSupport, connChatVersion} confId msg = do
cmdId <- withStore' $ \db -> createCommand db user (Just connId) CFAllowConn
@@ -2771,13 +2787,17 @@ allowAgentConnectionAsync user conn@Connection {connId, pqSupport, connChatVersi
withAgent $ \a -> allowConnectionAsync a (aCorrId cmdId) (aConnId conn) confId dm
withStore' $ \db -> updateConnectionStatus db conn ConnAccepted
agentAcceptContactAsync :: MsgEncodingI e => User -> Bool -> InvitationId -> ChatMsgEvent e -> SubscriptionMode -> PQSupport -> VersionChat -> CM (CommandId, ConnId)
agentAcceptContactAsync user enableNtfs invId msg subMode pqSup chatV = do
prepareAgentAccept :: User -> Bool -> InvitationId -> PQSupport -> CM (CommandId, ConnId)
prepareAgentAccept user enableNtfs invId pqSup = do
cmdId <- withStore' $ \db -> createCommand db user Nothing CFAcceptContact
dm <- encodeConnInfoPQ pqSup chatV msg
connId <- withAgent $ \a -> acceptContactAsync a (aUserId user) (aCorrId cmdId) enableNtfs invId dm pqSup subMode
connId <- withAgent $ \a -> prepareConnectionToAccept a (aUserId user) enableNtfs invId pqSup
pure (cmdId, connId)
agentAcceptContactAsync :: MsgEncodingI e => CommandId -> ConnId -> Bool -> InvitationId -> ChatMsgEvent e -> PQSupport -> VersionChat -> SubscriptionMode -> CM ()
agentAcceptContactAsync cmdId connId enableNtfs invId msg pqSup chatV subMode = do
dm <- encodeConnInfoPQ pqSup chatV msg
withAgent $ \a -> acceptContactAsync a (aCorrId cmdId) connId enableNtfs invId dm pqSup subMode
deleteAgentConnectionAsync :: ConnId -> CM ()
deleteAgentConnectionAsync acId = deleteAgentConnectionAsync' acId False
{-# INLINE deleteAgentConnectionAsync #-}
+23 -16
View File
@@ -639,9 +639,10 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
forM_ gli_ $ \GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do
groupInfo <- withStore $ \db -> getGroupInfo db cxt user groupId
subMode <- chatReadVar subscriptionMode
groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode
groupConnIds@(cmdId, connId) <- prepareAgentCreation user CFCreateConnGrpInv True SCMInvitation
gVar <- asks random
withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct' gLinkMemRole groupConnIds connChatVersion peerChatVRange subMode
withAgent $ \a -> createConnectionAsync a (aCorrId cmdId) connId True SCMInvitation CR.IKPQOff subMode
-- TODO REMOVE LEGACY ^^^
SENT msgId proxy -> do
void $ continueSending connEntity conn
@@ -1242,7 +1243,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo incognitoProfile
dm <- encodeXMemberConnInfo gInfo relayMemberId profileToSend
subMode <- chatReadVar subscriptionMode
void $ joinAgentConnectionAsync user (Just conn) True cReq dm subMode
(cmdId, connId') <- prepareAgentJoin user (Just conn) True cReq
joinAgentConnectionAsync cmdId True connId' True cReq dm subMode
CFGetRelayDataAccept -> do
let GroupMember {memberId = MemberId expectedMemberId} = m
if linkEntityId == Just expectedMemberId
@@ -1639,12 +1641,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
let sig = C.signatureBytes $ C.sign' privKey challenge
msg = XGrpRelayTest challenge (Just sig)
subMode <- chatReadVar subscriptionMode
chatVR <- chatVersionRange
let chatV = chatVR `peerConnChatVersion` chatVRange
(cmdId, acId) <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV
let chatV = vr cxt `peerConnChatVersion` chatVRange
(cmdId, acId) <- prepareAgentAccept user True invId PQSupportOff
withStore $ \db -> do
Connection {connId = testCId} <- createRelayTestConnection db cxt user acId ConnAccepted chatV subMode
liftIO $ setCommandConnId db user cmdId testCId
agentAcceptContactAsync cmdId acId True invId msg PQSupportOff chatV subMode
| otherwise = messageError "relay test sent to non-relay link"
where
User {userChatRelay} = user
@@ -2610,12 +2612,13 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
then do
subMode <- chatReadVar subscriptionMode
dm <- encodeConnInfo $ XGrpAcpt membershipMemId
connIds <- joinAgentConnectionAsync user Nothing True connRequest dm subMode
connIds@(cmdId, acId) <- prepareAgentJoin user Nothing True connRequest
withStore' $ \db -> do
setViaGroupLinkUri db groupId connId
createMemberConnectionAsync db user hostId connIds connChatVersion peerChatVRange subMode
updateGroupMemberStatusById db userId hostId GSMemAccepted
updateGroupMemberStatus db userId membership GSMemAccepted
joinAgentConnectionAsync cmdId False acId True connRequest dm subMode
toView $ CEvtUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct)
else do
let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole
@@ -3176,16 +3179,14 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
Just (ChatVersionRange mcvr)
| maxVersion mcvr >= groupDirectInvVersion -> do
subMode <- chatReadVar subscriptionMode
-- [async agent commands] commands should be asynchronous, continuation is to send XGrpMemInv - have to remember one has completed and process on second
groupConnIds <- createConn subMode
groupConnIds@(cmdId, connId) <- prepareAgentCreation user CFCreateConnGrpMemInv (chatHasNtfs chatSettings) SCMInvitation
let chatV = maybe (minVersion (vr cxt)) (\peerVR -> vr cxt `peerConnChatVersion` fromChatVRange peerVR) memChatVRange
void $ withStore $ \db -> do
reMember <- createIntroReMember db cxt user gInfo memInfo memRestrictions
createIntroReMemberConn db user m reMember chatV memInfo groupConnIds subMode
withAgent $ \a -> createConnectionAsync a (aCorrId cmdId) connId (chatHasNtfs chatSettings) SCMInvitation CR.IKPQOff subMode
| otherwise -> messageError "x.grp.mem.intro: member chat version range incompatible"
_ -> messageError "x.grp.mem.intro can be only sent by host member"
where
createConn subMode = createAgentConnectionAsync user CFCreateConnGrpMemInv (chatHasNtfs chatSettings) SCMInvitation subMode
sendXGrpMemInv :: Int64 -> Maybe ConnReqInvitation -> XGrpMemIntroCont -> CM ()
sendXGrpMemInv hostConnId directConnReq XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} = do
@@ -3228,12 +3229,16 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile gInfo membership $ fromLocalProfile $ memberProfile membership
dm <- encodeConnInfo $ XGrpMemInfo membershipMemId membershipProfile
-- [async agent commands] no continuation needed, but commands should be asynchronous for stability
groupConnIds <- joinAgentConnectionAsync user Nothing (chatHasNtfs chatSettings) groupConnReq dm subMode
directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user Nothing True dcr dm subMode
let enableNtfsGrp = chatHasNtfs chatSettings
groupConnIds@(gCmdId, gAcId) <- prepareAgentJoin user Nothing enableNtfsGrp groupConnReq
directConnIds <- mapM (prepareAgentJoin user Nothing True) directConnReq
let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo
mcvr = maybe chatInitialVRange fromChatVRange memChatVRange
chatV = vr cxt `peerConnChatVersion` mcvr
withStore' $ \db -> createIntroToMemberContact db user m toMember chatV mcvr groupConnIds directConnIds customUserProfileId subMode
joinAgentConnectionAsync gCmdId False gAcId enableNtfsGrp groupConnReq dm subMode
forM_ ((,) <$> directConnIds <*> directConnReq) $ \((dCmdId, dAcId), dcr) ->
joinAgentConnectionAsync dCmdId False dAcId True dcr dm subMode
-- rollback defense (channels): apply an owner-signed role/removal only at a version >= the persisted
-- roster_version (not the batch-constant gInfo, which a relay can stale by reordering events in one
@@ -3753,11 +3758,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
}
joinExistingContact subMode mCt@Contact {contactId = mContactId}
| autoAcceptMemberContacts user = do
(cmdId, acId) <- joinConn subMode
(cmdId, acId) <- prepareAgentJoin user Nothing True connReq
mCt' <- withStore $ \db -> do
updateMemberContactInvited db user mCt groupDirectInv
void $ liftIO $ createMemberContactConn db user acId (Just cmdId) g mConn ConnJoined mContactId subMode
getContact db cxt user mContactId
joinMemberContactAsync cmdId acId subMode
securityCodeChanged mCt'
createItems mCt' m
| otherwise = do
@@ -3771,13 +3777,14 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
createItems mCt' m
createNewContact subMode
| autoAcceptMemberContacts user = do
(cmdId, acId) <- joinConn subMode
(cmdId, acId) <- prepareAgentJoin user Nothing True connReq
-- [incognito] reuse membership incognito profile
(mCt, m') <- withStore $ \db -> do
(mContactId, m') <- liftIO $ createMemberContactInvited db user g m groupDirectInv
void $ liftIO $ createMemberContactConn db user acId (Just cmdId) g mConn ConnJoined mContactId subMode
mCt <- getContact db cxt user mContactId
pure (mCt, m')
joinMemberContactAsync cmdId acId subMode
createInternalChatItem user (CDDirectSnd mCt) CIChatBanner (Just epochStart)
createItems mCt m'
| otherwise = do
@@ -3790,12 +3797,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
createInternalChatItem user (CDDirectSnd mCt) CIChatBanner (Just epochStart)
createInternalChatItem user (CDDirectRcv mCt) (CIRcvDirectEvent $ RDEGroupInvLinkReceived gp) Nothing
createItems mCt m'
joinConn subMode = do
joinMemberContactAsync cmdId acId subMode = do
-- [incognito] send membership incognito profile
p <- presentUserBadge user (incognitoMembershipProfile g) $ userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile g) Nothing True
-- TODO PQ should negotitate contact connection with PQSupportOn? (use encodeConnInfoPQ)
dm <- encodeConnInfo $ XInfo p
joinAgentConnectionAsync user Nothing True connReq dm subMode
joinAgentConnectionAsync cmdId False acId True connReq dm subMode
createItems mCt' m' = do
(g', m'', scopeInfo) <- mkGroupChatScope g m'
createInternalChatItem user (CDGroupRcv g' scopeInfo m'') (CIRcvGroupEvent RGEMemberCreatedContact) Nothing
+3 -1
View File
@@ -449,9 +449,9 @@ testJoinGroup ps =
cath <## ""
cath <## "Send captcha text to join the group privacy."
captcha <- dropStrPrefix "#privacy (support) 'SimpleX Directory_1'> " . dropTime <$> getTermLine cath
cath #> ("#privacy (support) " <> captcha)
cath <## "contact and member are merged: 'SimpleX Directory', #privacy 'SimpleX Directory_1'"
cath <## "use @'SimpleX Directory' <message> to send messages"
cath #> ("#privacy (support) " <> captcha)
cath <# ("#privacy (support) 'SimpleX Directory'!> > cath " <> captcha)
cath <## " Correct, you joined the group privacy"
cath <## "#privacy: you joined the group"
@@ -1277,6 +1277,8 @@ testKnocking ps =
cath <## "#privacy: you joined the group, connecting to group moderators for admission to group"
cath <## "#privacy: 'SimpleX Directory' accepted you to the group, pending review"
bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting and pending review...), use /_accept member #1 3 <role> to accept member"
cath <## "#privacy: member bob (Bob) is connected"
bob <## "#privacy: new member cath is connected and pending review, use /_accept member #1 3 <role> to accept member"
testCaptchaByDefault :: HasCallStack => TestParams -> IO ()
testCaptchaByDefault ps =