diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index a6ddc97e19..6666de7587 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -54,7 +54,7 @@ import Simplex.Chat.Core import Simplex.Chat.Markdown (Format (..), FormattedText (..), parseMaybeMarkdownList, viewName) import Simplex.Chat.Messages import Simplex.Chat.Options -import Simplex.Chat.Protocol (MsgContent (..)) +import Simplex.Chat.Protocol (MsgContent (..), memberSupportVoiceVersion) import Simplex.Chat.Store.Direct (getContact) import Simplex.Chat.Store.Groups (getGroupLink, getGroupMember, setGroupCustomData) -- TODO remove setGroupCustomData import Simplex.Chat.Store.Profiles (GroupLinkInfo (..), getGroupLinkInfo) @@ -569,7 +569,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName a = groupMemberAcceptance g captchaNotice = "Captcha is generated by SimpleX Directory service.\n\n*Send captcha text* to join the group " <> displayName <> "." - <> if isJust (voiceCaptchaGenerator opts) then "\nSend /audio to receive a voice captcha." else "" + <> if canSendVoiceCaptcha g m then "\nSend /audio to receive a voice captcha." else "" sendMemberCaptcha :: GroupInfo -> GroupMember -> Maybe ChatItemId -> Text -> Int -> CaptchaMode -> IO () sendMemberCaptcha GroupInfo {groupId} m quotedId noticeText prevAttempts mode = do @@ -614,6 +614,11 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName img : _ -> MCImage "" $ ImageData img textMsg = MCText $ T.pack s + canSendVoiceCaptcha :: GroupInfo -> GroupMember -> Bool + canSendVoiceCaptcha gInfo m = + isJust (voiceCaptchaGenerator opts) + && (groupFeatureUserAllowed SGFVoice gInfo || supportsVersion m memberSupportVoiceVersion) + approvePendingMember :: DirectoryMemberAcceptance -> GroupInfo -> GroupMember -> IO () approvePendingMember a g@GroupInfo {groupId} m@GroupMember {memberProfile = LocalProfile {displayName, image}} = do gli_ <- join . eitherToMaybe <$> withDB' "getGroupLinkInfo" cc (\db -> getGroupLinkInfo db userId groupId) @@ -637,16 +642,20 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName isAudioCmd = T.strip msgText == "/audio" cmd = fromRight (ADC SDRUser DCUnknownCommand) $ A.parseOnly (directoryCmdP <* A.endOfInput) $ T.strip msgText atomically (TM.lookup gmId $ pendingCaptchas env) >>= \case - Nothing -> - let mode = if isAudioCmd then CMAudio else CMText - in sendMemberCaptcha g m (Just ciId) noCaptcha 0 mode + Nothing + | isAudioCmd && canSendVoiceCaptcha g m -> sendMemberCaptcha g m (Just ciId) noCaptcha 0 CMAudio + | isAudioCmd -> sendComposedMessages_ cc sendRef [(Just ciId, MCText voiceCaptchaUnavailable)] + | otherwise -> sendMemberCaptcha g m (Just ciId) noCaptcha 0 CMText Just pc@PendingCaptcha {captchaText, sentAt, attempts, captchaMode} - | isAudioCmd -> case captchaMode of - CMText -> do - atomically $ TM.insert gmId pc {captchaMode = CMAudio} $ pendingCaptchas env - sendVoiceCaptcha sendRef (T.unpack captchaText) - CMAudio -> - sendComposedMessages_ cc sendRef [(Just ciId, MCText audioAlreadyEnabled)] + | isAudioCmd -> + if canSendVoiceCaptcha g m + then case captchaMode of + CMText -> do + atomically $ TM.insert gmId pc {captchaMode = CMAudio} $ pendingCaptchas env + sendVoiceCaptcha sendRef (T.unpack captchaText) + CMAudio -> + sendComposedMessages_ cc sendRef [(Just ciId, MCText audioAlreadyEnabled)] + else sendComposedMessages_ cc sendRef [(Just ciId, MCText voiceCaptchaUnavailable)] | otherwise -> case cmd of ADC SDRUser (DCSearchGroup _) -> do ts <- getCurrentTime @@ -679,6 +688,8 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName noCaptcha = "Unexpected message, please try again." audioAlreadyEnabled :: Text audioAlreadyEnabled = "Audio captcha is already enabled." + voiceCaptchaUnavailable :: Text + voiceCaptchaUnavailable = "Voice captcha is not available - please update SimpleX Chat to v6.5+ or use text captcha." unknownCommand :: Text unknownCommand = "Unknown command, please enter captcha text." tooManyAttempts :: Text diff --git a/docs/rfcs/2026-02-10-member-support-voice.md b/docs/rfcs/2026-02-10-member-support-voice.md new file mode 100644 index 0000000000..52285d1514 --- /dev/null +++ b/docs/rfcs/2026-02-10-member-support-voice.md @@ -0,0 +1,212 @@ +# Voice messages in member support scope + +## Table of contents + +1. Executive summary +2. Problem +3. High-level design +4. Detailed implementation plan + +## 1. Executive summary + +Allow voice messages from host/admin during the approval phase (member pending) regardless of group voice settings, gated behind chat protocol version 17. This enables the directory bot to send voice captchas in groups that prohibit voice messages. Old clients that don't support this exemption will receive text/image captchas instead. + +## 2. Problem + +The directory bot sends voice captchas to joining members via the member support scope (`GCSMemberSupport`). However, `prohibitedGroupContent` (Internal.hs:338) blocks voice messages when the group disables voice — with no scope exemption: + +```haskell +| isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) = Just GFVoice +``` + +Other content types (files, reports, simplex links) already have `isNothing scopeInfo` guards that exempt them in member support scope. Voice does not. + +This means voice captchas fail in the majority of real groups that prohibit voice messages. The check runs on both sender side (Commands.hs:3856) and recipient side (Subscriber.hs:1738), so both the bot and the joining member reject voice in these groups. + +## 3. High-level design + +1. **Protocol version 17** (`memberSupportVoiceVersion`): gates the `prohibitedGroupContent` exemption for host voice during the approval phase. + +2. **Core library change** (Internal.hs): exempt voice in `prohibitedGroupContent` when sender is admin+ (host) AND the member is in the approval phase (pending status). Voice is NOT generally allowed in member support scope — only during approval, only from host. + +3. **Directory bot change** (Service.hs): check member's protocol version and group voice settings before offering or sending voice captcha. Fall back to text/image captcha for old clients in voice-disabled groups. + +## 4. Detailed implementation plan + +### 4.1. Protocol.hs — add version 17 + +**File:** `src/Simplex/Chat/Protocol.hs` + +Add to version history comment (after line 79): + +``` +-- 17 - allow host voice messages during member approval regardless of group voice setting (2026-02-10) +``` + +Update `currentChatVersion` (line 85): + +```haskell +currentChatVersion = VersionChat 17 +``` + +Add version constant (after `shortLinkDataVersion`, line 146): + +```haskell +-- support host voice messages during member approval regardless of group voice setting +memberSupportVoiceVersion :: VersionChat +memberSupportVoiceVersion = VersionChat 17 +``` + +### 4.2. Internal.hs — exempt host voice during approval phase + +**File:** `src/Simplex/Chat/Library/Internal.hs` + +Change function header (line 337) to bind sender's role and full membership: + +```haskell +prohibitedGroupContent gInfo@GroupInfo {membership = mem@GroupMember {memberRole = userRole}} m@GroupMember {memberRole = senderRole} scopeInfo mc ft file_ sent +``` + +Change line 338 from: + +```haskell + | isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) = Just GFVoice +``` + +to: + +```haskell + | isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) && not hostApprovalVoice = Just GFVoice +``` + +Add to the `where` clause: + +```haskell + hostApprovalVoice = senderRole >= GRAdmin && inApprovalPhase + inApprovalPhase = case scopeInfo of + Just (GCSIMemberSupport (Just scopeMem)) -> memberPending scopeMem + Just (GCSIMemberSupport Nothing) -> memberPending mem + Nothing -> False +``` + +Note: `memberPending` returns True for both `GSMemPendingApproval` and `GSMemPendingReview`. The exemption applies to both phases — the member hasn't been fully admitted in either state. + +**Why two cases for `inApprovalPhase`:** + +- **Sender side** (bot sending via Commands.hs:3856): `scopeInfo = GCSIMemberSupport (Just pendingMember)` — the scope contains the pending member being supported. `memberPending pendingMember` checks their status. +- **Receiver side** (member receiving via Subscriber.hs:1738): `scopeInfo = GCSIMemberSupport Nothing` — `Nothing` means the member's own support conversation (constructed by `mkGroupSupportChatInfo` in Internal.hs:1535). `memberPending mem` checks the local user's (receiving member's) status. + +**Behavior matrix:** + +| Scenario | `hostApprovalVoice` | Voice allowed? | +|----------|---------------------|----------------| +| Host → pending member, voice disabled | True | Yes (new) | +| Host → approved member in support, voice disabled | False (`memberPending` = False) | No | +| Pending member → host, voice disabled | False (`senderRole` < GRAdmin) | No | +| Anyone outside support scope, voice disabled | False (`inApprovalPhase` = False) | No | +| Any sender, voice enabled | N/A (`groupFeatureMemberAllowed` = True) | Yes (existing) | + +**Version gating:** Old clients (< v17) don't have this exemption. On the sender side this is handled by the bot (4.3). On the recipient side: + +- Old recipient + voice-disabled group: recipient rejects the voice message (shows "Voice messages: received, prohibited") +- This is why the bot must check the member's version before sending voice + +### 4.3. Service.hs — version-aware voice captcha logic + +**File:** `apps/simplex-directory-service/src/Directory/Service.hs` + +#### 4.3.1. Add import + +Add `memberSupportVoiceVersion` to the `Protocol` import: + +```haskell +import Simplex.Chat.Protocol (MsgContent (..), memberSupportVoiceVersion) +``` + +#### 4.3.2. Add helper predicate + +Add a helper in the `directoryService` `where` block (same scope as `sendMemberCaptcha`, `sendVoiceCaptcha`, etc., where `opts` is in scope): + +```haskell +canSendVoiceCaptcha :: GroupInfo -> GroupMember -> Bool +canSendVoiceCaptcha gInfo m = + isJust (voiceCaptchaGenerator opts) + && (groupFeatureUserAllowed SGFVoice gInfo || supportsVersion m memberSupportVoiceVersion) +``` + +Logic: +- Voice captcha generator must be configured +- AND either the group allows voice for the bot/host (any client version works — old clients accept voice from permitted senders) OR the member's client supports v17 (exemption applies on receive side) + +Note: `groupFeatureUserAllowed` checks if the bot (group owner) is permitted to send voice. This is what the recipient's `prohibitedGroupContent` checks — it validates the *sender's* permission (`m` parameter = sender's GroupMember), not the recipient's. Using `groupFeatureMemberAllowed SGFVoice m gInfo` (joining member) would be wrong: it would incorrectly block voice captcha in groups with role-based voice settings (e.g., "admins only"). + +#### 4.3.3. Update `dePendingMember` hint text (line 572) + +Change from: + +```haskell +<> if isJust (voiceCaptchaGenerator opts) then "\nSend /audio to receive a voice captcha." else "" +``` + +to: + +```haskell +<> if canSendVoiceCaptcha g m then "\nSend /audio to receive a voice captcha." else "" +``` + +This hides the `/audio` hint when voice captcha cannot be delivered. + +#### 4.3.4. Update `dePendingMemberMsg` `/audio` handling (lines 644-649) + +When a member sends `/audio`, check `canSendVoiceCaptcha` before switching mode. If voice captcha is not possible, reply with an upgrade message: + +```haskell +| isAudioCmd -> + if canSendVoiceCaptcha g m + then case captchaMode of + CMText -> do + atomically $ TM.insert gmId pc {captchaMode = CMAudio} $ pendingCaptchas env + sendVoiceCaptcha sendRef (T.unpack captchaText) + CMAudio -> + sendComposedMessages_ cc sendRef [(Just ciId, MCText audioAlreadyEnabled)] + else sendComposedMessages_ cc sendRef [(Just ciId, MCText voiceCaptchaUnavailable)] +``` + +#### 4.3.5. Add message constant + +```haskell +voiceCaptchaUnavailable :: Text +voiceCaptchaUnavailable = "Voice captcha is not available - please update SimpleX Chat to v6.5+ or use text captcha." +``` + +#### 4.3.6. Update `dePendingMemberMsg` no-captcha `/audio` path (lines 640-642) + +Same check for the case when no pending captcha exists yet: + +```haskell +Nothing -> + if isAudioCmd && canSendVoiceCaptcha g m + then sendMemberCaptcha g m (Just ciId) noCaptcha 0 CMAudio + else if isAudioCmd + then sendComposedMessages_ cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [(Just ciId, MCText voiceCaptchaUnavailable)] + else let mode = CMText + in sendMemberCaptcha g m (Just ciId) noCaptcha 0 mode +``` + +### 4.4. Tests + +**File:** `tests/Bots/DirectoryTests.hs` + +Update existing audio captcha tests to cover: +1. Group with voice enabled + any client version: `/audio` works (existing behavior) +2. Group with voice disabled + member version >= 17: `/audio` works +3. Group with voice disabled + member version < 17: `/audio` shows unavailable message, hint is hidden + +### 4.5. Changes summary + +| File | Change | Lines affected | +|------|--------|----------------| +| `Protocol.hs` | Add v17 constant, bump `currentChatVersion` | ~4 lines added | +| `Internal.hs` | Exempt host voice during approval phase | ~6 lines modified/added | +| `Service.hs` | Version-aware voice captcha logic | ~15 lines modified/added | +| `DirectoryTests.hs` | Test coverage for version gating | TBD | diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 607839ed36..896dfbb747 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -334,13 +334,22 @@ quoteContent mc qmc ciFile_ qTextOrFile = if T.null qText then qFileName else qText prohibitedGroupContent :: GroupInfo -> GroupMember -> Maybe GroupChatScopeInfo -> MsgContent -> Maybe MarkdownList -> Maybe f -> Bool -> Maybe GroupFeature -prohibitedGroupContent gInfo@GroupInfo {membership = GroupMember {memberRole = userRole}} m scopeInfo mc ft file_ sent - | isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) = Just GFVoice +prohibitedGroupContent gInfo@GroupInfo {membership = mem@GroupMember {memberRole = userRole}} m scopeInfo mc ft file_ sent + | isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) && not hostApprovalVoice = Just GFVoice | isNothing scopeInfo && not (isVoice mc) && isJust file_ && not (groupFeatureMemberAllowed SGFFiles m gInfo) = Just GFFiles | isNothing scopeInfo && isReport mc && (badReportUser || not (groupFeatureAllowed SGFReports gInfo)) = Just GFReports | isNothing scopeInfo && prohibitedSimplexLinks gInfo m ft = Just GFSimplexLinks | otherwise = Nothing where + hostApprovalVoice + | sent = userRole >= GRAdmin && sendApprovalPhase + | otherwise = memberCategory m == GCHostMember && hostApprovalPhase + hostApprovalPhase = case scopeInfo of + Just (GCSIMemberSupport Nothing) -> memberStatus mem == GSMemPendingApproval + _ -> False + sendApprovalPhase = case scopeInfo of + Just (GCSIMemberSupport (Just scopeMem)) -> memberStatus scopeMem == GSMemPendingApproval + _ -> False -- admins cannot send reports, non-admins cannot receive reports badReportUser | sent = userRole >= GRModerator diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 3272bc0115..e7a52f5153 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -77,12 +77,13 @@ import Simplex.Messaging.Version hiding (version) -- 14 - support sending and receiving group join rejection (2025-02-24) -- 15 - support specifying message scopes for group messages (2025-03-12) -- 16 - support short link data (2025-06-10) +-- 17 - allow host voice messages during member approval regardless of group voice setting (2026-02-10) -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. currentChatVersion :: VersionChat -currentChatVersion = VersionChat 16 +currentChatVersion = VersionChat 17 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) supportedChatVRange :: VersionRangeChat @@ -145,6 +146,10 @@ groupKnockingVersion = VersionChat 15 shortLinkDataVersion :: VersionChat shortLinkDataVersion = VersionChat 16 +-- support host voice messages during member approval regardless of group voice setting +memberSupportVoiceVersion :: VersionChat +memberSupportVoiceVersion = VersionChat 17 + agentToChatVersion :: VersionSMPA -> VersionChat agentToChatVersion v | v < pqdrSMPAgentVersion = initialChatVersion diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index 1b881bd446..9fb0f40928 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -1205,10 +1205,6 @@ Query: UPDATE ratchets SET ratchet_state = ? WHERE conn_id = ? Plan: SEARCH ratchets USING PRIMARY KEY (conn_id=?) -Query: UPDATE rcv_file_chunk_replicas SET delay = ?, retries = retries + 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? -Plan: -SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE rcv_file_chunk_replicas SET received = 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? Plan: SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 6201c8b2e8..c5c110159a 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -4741,14 +4741,6 @@ Query: Plan: SEARCH protocol_servers USING INTEGER PRIMARY KEY (rowid=?) -Query: - UPDATE rcv_files - SET to_receive = 1, user_approved_relays = ?, updated_at = ? - WHERE file_id = ? - -Plan: -SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE remote_controllers SET ctrl_device_name = ?, dh_priv_key = ?, prev_dh_priv_key = dh_priv_key diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 284a069bab..852e4cf4c4 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -73,6 +73,8 @@ directoryServiceTests = do it "should ask member to pass captcha screen" testCapthaScreening it "should send voice captcha on /audio command" testVoiceCaptchaScreening it "should retry with voice captcha after switching to audio mode" testVoiceCaptchaRetry + it "should send voice captcha when voice disabled but client supports v17" testVoiceCaptchaVoiceDisabled + it "should show unavailable message for old client in voice-disabled group" testVoiceCaptchaOldClient it "should reject member after too many captcha attempts" testCaptchaTooManyAttempts it "should respond to unknown command during captcha" testCaptchaUnknownCommand describe "store log" $ do @@ -1369,6 +1371,139 @@ testVoiceCaptchaRetry ps@TestParams {tmpPath} = do cath <#. "#privacy (support) 'SimpleX Directory'> sends file " cath <##. "use /fr 2" +testVoiceCaptchaVoiceDisabled :: HasCallStack => TestParams -> IO () +testVoiceCaptchaVoiceDisabled ps@TestParams {tmpPath} = do + let mockScript = tmpPath "mock_voice_gen_vdisabled.py" + writeFile mockScript $ unlines + [ "#!/usr/bin/env python3", + "import os, tempfile", + "out = os.environ.get('VOICE_CAPTCHA_OUT')", + "if not out:", + " fd, out = tempfile.mkstemp(suffix='.m4a')", + " os.close(fd)", + "open(out, 'wb').write(b'\\x00' * 100)", + "print(out)", + "print(5)" + ] + setPermissions mockScript $ setOwnerExecutable True $ setOwnerReadable True $ setOwnerWritable True emptyPermissions + withDirectoryServiceVoiceCaptcha ps mockScript $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + bob #> "@'SimpleX Directory' /role 1" + bob <# "'SimpleX Directory'> > /role 1" + bob <## " The initial member role for the group privacy is set to member" + bob <## "Send /'role 1 observer' to change it." + bob <## "" + note <- getTermLine bob + let groupLink = dropStrPrefix "Please note: it applies only to members joining via this link: " note + bob #> "@'SimpleX Directory' /filter 1 captcha" + bob <# "'SimpleX Directory'> > /filter 1 captcha" + bob <## " Spam filter settings for group privacy set to:" + bob <## "- reject long/inappropriate names: disabled" + bob <## "- pass captcha to join: enabled" + bob <## "" + bob <## "/'filter 1 name' - enable name filter" + bob <## "/'filter 1 name captcha' - enable both" + bob <## "/'filter 1 off' - disable filter" + -- disable voice messages in the group + bob ##> "/set voice #privacy off" + bob <## "updated group preferences:" + bob <## "Voice messages: off" + -- cath (new client, supports v17 exemption) joins, /audio hint shown + cath ##> ("/c " <> groupLink) + cath <## "connection request sent!" + cath <## "#privacy: joining the group..." + cath <## "#privacy: you joined the group, pending approval" + cath <# "#privacy (support) 'SimpleX Directory'> Captcha is generated by SimpleX Directory service." + cath <## "" + cath <## "Send captcha text to join the group privacy." + cath <## "Send /audio to receive a voice captcha." + captcha <- dropStrPrefix "#privacy (support) 'SimpleX Directory'> " . dropTime <$> getTermLine cath + -- voice captcha works despite voice being disabled (v17 host approval exemption) + cath #> "#privacy (support) /audio" + cath <# "#privacy (support) 'SimpleX Directory'> voice message (00:05)" + cath <#. "#privacy (support) 'SimpleX Directory'> sends file " + cath <##. "use /fr 1" + sendCaptcha cath captcha + cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" + cath <## "#privacy: member bob (Bob) is connected" + bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" + bob <## "#privacy: new member cath is connected" + where + sendCaptcha cath captcha = do + cath #> ("#privacy (support) " <> captcha) + cath <# ("#privacy (support) 'SimpleX Directory'!> > cath " <> captcha) + cath <## " Correct, you joined the group privacy" + cath <## "#privacy: you joined the group" + +testVoiceCaptchaOldClient :: HasCallStack => TestParams -> IO () +testVoiceCaptchaOldClient ps@TestParams {tmpPath} = do + let mockScript = tmpPath "mock_voice_gen_oldclient.py" + writeFile mockScript $ unlines + [ "#!/usr/bin/env python3", + "import os, tempfile", + "out = os.environ.get('VOICE_CAPTCHA_OUT')", + "if not out:", + " fd, out = tempfile.mkstemp(suffix='.m4a')", + " os.close(fd)", + "open(out, 'wb').write(b'\\x00' * 100)", + "print(out)", + "print(5)" + ] + setPermissions mockScript $ setOwnerExecutable True $ setOwnerReadable True $ setOwnerWritable True emptyPermissions + withDirectoryServiceVoiceCaptcha ps mockScript $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgVPrev "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + bob #> "@'SimpleX Directory' /role 1" + bob <# "'SimpleX Directory'> > /role 1" + bob <## " The initial member role for the group privacy is set to member" + bob <## "Send /'role 1 observer' to change it." + bob <## "" + note <- getTermLine bob + let groupLink = dropStrPrefix "Please note: it applies only to members joining via this link: " note + bob #> "@'SimpleX Directory' /filter 1 captcha" + bob <# "'SimpleX Directory'> > /filter 1 captcha" + bob <## " Spam filter settings for group privacy set to:" + bob <## "- reject long/inappropriate names: disabled" + bob <## "- pass captcha to join: enabled" + bob <## "" + bob <## "/'filter 1 name' - enable name filter" + bob <## "/'filter 1 name captcha' - enable both" + bob <## "/'filter 1 off' - disable filter" + -- disable voice messages in the group + bob ##> "/set voice #privacy off" + bob <## "updated group preferences:" + bob <## "Voice messages: off" + -- cath (old client, max version < v17) joins, /audio hint NOT shown + cath ##> ("/c " <> groupLink) + cath <## "connection request sent!" + cath <## "#privacy: joining the group..." + cath <## "#privacy: you joined the group, pending approval" + cath <# "#privacy (support) 'SimpleX Directory'> Captcha is generated by SimpleX Directory service." + cath <## "" + cath <## "Send captcha text to join the group privacy." + captcha <- dropStrPrefix "#privacy (support) 'SimpleX Directory'> " . dropTime <$> getTermLine cath + -- /audio unavailable: old client can't receive voice in voice-disabled group + cath #> "#privacy (support) /audio" + cath <# "#privacy (support) 'SimpleX Directory'!> > cath /audio" + cath <## " Voice captcha is not available - please update SimpleX Chat to v6.5+ or use text captcha." + -- text captcha still works + sendCaptcha cath captcha + cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" + cath <## "#privacy: member bob (Bob) is connected" + bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" + bob <## "#privacy: new member cath is connected" + where + sendCaptcha cath captcha = do + cath #> ("#privacy (support) " <> captcha) + cath <# ("#privacy (support) 'SimpleX Directory'!> > cath " <> captcha) + cath <## " Correct, you joined the group privacy" + cath <## "#privacy: you joined the group" + withDirectoryServiceVoiceCaptcha :: HasCallStack => TestParams -> FilePath -> (TestCC -> String -> IO ()) -> IO () withDirectoryServiceVoiceCaptcha ps voiceScript test = do dsLink <- diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 2332fa429c..d607d1f208 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -133,7 +133,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"1-16\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1-17\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new quote" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -249,13 +249,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} Nothing it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-16\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-17\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} Nothing it "x.grp.mem.intro" $ "{\"v\":\"1\",\"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=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} Nothing it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-16\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-17\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" @@ -270,7 +270,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%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=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-16\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-17\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"