From 0ae4d4ddb9808a146e771125d1aa6d9ff64467e9 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 15 May 2026 18:08:45 +0400 Subject: [PATCH] wip --- .../Views/Chat/Group/ChannelRelaysView.swift | 4 +- apps/ios/product/views/group-info.md | 3 +- apps/ios/spec/client/chat-view.md | 3 +- bots/api/COMMANDS.md | 38 +++++++++++++++++++ bots/src/API/Docs/Commands.hs | 2 + bots/src/API/Docs/Responses.hs | 1 + docs/protocol/channels-protocol.md | 2 +- .../types/typescript/src/commands.ts | 14 +++++++ .../types/typescript/src/responses.ts | 8 ++++ .../src/simplex_chat/types/_commands.py | 12 ++++++ .../src/simplex_chat/types/_responses.py | 8 +++- src/Simplex/Chat/Library/Subscriber.hs | 9 +++-- tests/ChatTests/Groups.hs | 13 ++++++- 13 files changed, 108 insertions(+), 9 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift index cf6426fe15..7b3935e4b2 100644 --- a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift +++ b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift @@ -35,7 +35,9 @@ struct ChannelRelaysView: View { } } .sheet(isPresented: $showAddRelay) { - let existingRelayIds = Set(groupRelays.filter { $0.relayStatus != .rsInactive }.compactMap { $0.userChatRelay.chatRelayId }) + // Backend gate (APIAddGroupRelays) rejects any chatRelayId already in group_relays + // regardless of relayStatus, so all current rows must be excluded from the add list. + let existingRelayIds = Set(groupRelays.compactMap { $0.userChatRelay.chatRelayId }) AddGroupRelayView(groupInfo: groupInfo, existingRelayIds: existingRelayIds) { Task { await chatModel.loadGroupMembers(groupInfo) } } diff --git a/apps/ios/product/views/group-info.md b/apps/ios/product/views/group-info.md index c2bac2d30d..da95877405 100644 --- a/apps/ios/product/views/group-info.md +++ b/apps/ios/product/views/group-info.md @@ -188,6 +188,7 @@ New view accessible from channel info, showing relay members (role == `.relay`): | Relay list | Filtered from `chatModel.groupMembers` by `.relay` role | | Relay row | Profile image, relay display name, status text (`RelayStatus` or connection status) | | Relay tap | NavigationLink to `GroupMemberInfoView` with `groupRelay:` parameter | +| Add relay sheet | Owner-only "Add relay" button opens `AddGroupRelayView`; the available-to-add list excludes any `chatRelayId` already present in `groupRelays` (regardless of `relayStatus`), so inactive or rejected relays cannot be re-added without first removing them via the row's swipe action | | Empty state | "No chat relays" | | Footer | "Chat relays forward messages to channel subscribers." | @@ -221,7 +222,7 @@ Owner sees relay status from `apiGetGroupRelays`; non-owner sees connection stat | "Unblock for all?" alert | "Unblock subscriber for all?" | | Relay link info row | Shown when `member.relayLink` exists, displays `hostFromRelayLink(link)` | | Relay address info row | Shown when `groupRelay?.userChatRelay.address` exists, with "Share relay address" button | -| Status row (rejected) | Shown when `groupRelay?.relayStatus == .rsRejected`: "Status: rejected by relay operator". Indicates the relay rejected the invitation to rejoin this channel after a prior `/leave` — clearable only by the relay operator via `/relay allow `. | +| Status row (rejected) | Shown when `groupRelay?.relayStatus == .rsRejected`: "Status: rejected by relay operator". The relay rejected the invitation to rejoin this channel after a prior `/leave`; the owner-side `GroupMember.memberStatus` is also set to `.memLeft` so the relay renders identically to one that explicitly left. Clearable only by the relay operator running `/relay allow #`. | | Relay footer | Owner: "Subscribers use relay link to connect to the channel. Relay address was used to set up this relay for the channel." Non-owner: "You connected to the channel via this relay link." | ## Related Specs diff --git a/apps/ios/spec/client/chat-view.md b/apps/ios/spec/client/chat-view.md index c693b5569e..2bf897911b 100644 --- a/apps/ios/spec/client/chat-view.md +++ b/apps/ios/spec/client/chat-view.md @@ -352,10 +352,11 @@ Groups use separate [`groupLinkButton()`](../../Shared/Views/Chat/Group/GroupCha Navigates to relay list view with role-based branches: - **Owner**: loads `[GroupRelay]` via [`apiGetGroupRelays`](../../Shared/Model/SimpleXAPI.swift#L1839) (owner-only API, guarded by `assertUserGroupRole GROwner` on backend). Joins with `chatModel.groupMembers` by `groupMemberId` for display names. Shows status indicators (colored circle + `RelayStatus.text`). When `relayStatus == .rsRejected` the indicator dot is red and the text reads "rejected", matching the `connFailed`/`removed` rendering. - **Member**: filters `chatModel.groupMembers` by `.memberRole == .relay`. Shows relay member display names only (no status data). +- **Add relay sheet**: `existingRelayIds` excludes every `chatRelayId` present in `groupRelays` regardless of `relayStatus`, so an already-listed relay (including `.rsInactive` and `.rsRejected`) cannot be re-added from the sheet. This mirrors the backend gate at `APIAddGroupRelays` (`existingRelayIds`), which rejects duplicate `chatRelayId`s; operator must remove the relay first via the swipe action. ### Relay Rejection Surface -When a relay operator runs `/leave #channel`, the relay sends `x.grp.relay.reject` over the owner-relay direct contact channel. Owner-side handling: the corresponding `GroupRelay.relayStatus` transitions `RSInvited → RSRejected`; the relay's `GroupMember.memberStatus` becomes `.memRejected`. The transition surfaces through `CEvtGroupRelayUpdated`. In `GroupMemberInfoView`, an additional `Status: rejected by relay operator` info row appears when `groupRelay?.relayStatus == .rsRejected`. The status is final on the owner side — clearable only by the relay operator running `/relay allow `, which has no owner-facing event. +When a relay operator runs `/leave #channel`, the relay sends `x.grp.relay.reject` over the owner-relay direct contact channel. Owner-side handling: the corresponding `GroupRelay.relayStatus` transitions `RSInvited → RSRejected`; the relay's `GroupMember.memberStatus` is set to `.memLeft` so the owner UI renders the rejected relay identically to one that explicitly ran `/leave` (`.memRejected` is reserved for the knocking-admission flow). The transition surfaces through `CEvtGroupRelayUpdated`. In `GroupMemberInfoView`, an additional `Status: rejected by relay operator` info row appears when `groupRelay?.relayStatus == .rsRejected`. The status is final on the owner side — clearable only by the relay operator running `/relay allow #`, which has no owner-facing event. ### Leave Button Logic diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index 5761f303bd..8038cc5a20 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -33,6 +33,7 @@ This file is generated automatically. - [APINewPublicGroup](#apinewpublicgroup) - [APIGetGroupRelays](#apigetgrouprelays) - [APIAddGroupRelays](#apiaddgrouprelays) +- [APIAllowRelayGroup](#apiallowrelaygroup) - [APIUpdateGroupProfile](#apiupdategroupprofile) [Group link commands](#group-link-commands) @@ -1080,6 +1081,43 @@ ChatCmdError: Command error (only used in WebSockets API). --- +### APIAllowRelayGroup + +Clear relay rejection for a channel (relay operator). + +*Network usage*: background. + +**Parameters**: +- groupId: int64 + +**Syntax**: + +``` +/_relay allow # +``` + +```javascript +'/_relay allow #' + groupId // JavaScript +``` + +```python +'/_relay allow #' + str(groupId) # Python +``` + +**Responses**: + +RelayGroupAllowed: Relay rejection cleared for a channel. +- type: "relayGroupAllowed" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- + + ### APIUpdateGroupProfile Update group profile. diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index b3eaf96837..8894609758 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -120,6 +120,7 @@ chatCommandsDocsData = ("APINewPublicGroup", [], "Create public group.", ["CRPublicGroupCreated", "CRPublicGroupCreationFailed", "CRChatCmdError"], [], Just UNInteractive, "/_public group " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> " " <> Join ',' "relayIds" <> " " <> Json "groupProfile"), ("APIGetGroupRelays", [], "Get group relays.", ["CRGroupRelays", "CRChatCmdError"], [], Nothing, "/_get relays #" <> Param "groupId"), ("APIAddGroupRelays", [], "Add relays to group.", ["CRGroupRelaysAdded", "CRGroupRelaysAddFailed", "CRChatCmdError"], [], Just UNInteractive, "/_add relays #" <> Param "groupId" <> " " <> Join ',' "relayIds"), + ("APIAllowRelayGroup", [], "Clear relay rejection for a channel (relay operator).", ["CRRelayGroupAllowed", "CRChatCmdError"], [], Just UNBackground, "/_relay allow #" <> Param "groupId"), ("APIUpdateGroupProfile", [], "Update group profile.", ["CRGroupUpdated", "CRChatCmdError"], [], Just UNBackground, "/_group_profile #" <> Param "groupId" <> " " <> Json "groupProfile") ] ), @@ -203,6 +204,7 @@ cliCommands = "AcceptMember", "AddContact", "AddMember", + "AllowRelayGroup", "BlockForAll", "ChatHelp", "ClearContact", diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 55f12f0a0a..ddd127241b 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -73,6 +73,7 @@ chatResponsesDocsData = ("CRGroupRelays", ""), ("CRGroupRelaysAdded", ""), ("CRGroupRelaysAddFailed", ""), + ("CRRelayGroupAllowed", "Relay rejection cleared for a channel"), ("CRGroupMembers", ""), ("CRGroupUpdated", ""), ("CRGroupsList", "Groups"), diff --git a/docs/protocol/channels-protocol.md b/docs/protocol/channels-protocol.md index c77f112add..593ae4081a 100644 --- a/docs/protocol/channels-protocol.md +++ b/docs/protocol/channels-protocol.md @@ -80,7 +80,7 @@ When a relay operator removes the relay from a channel, the relay marks the chan 2. **Refuse.** When the owner later sends `x.grp.relay.inv` for the same channel link — typically from a re-invitation — the relay does not accept the invitation as a relay. Instead it replies with `x.grp.relay.reject` over the owner-relay direct contact channel, carrying a rejection reason. The current reason is `rejoin_rejected`; older relays or future reasons fall through to an unknown reason for forward compatibility. -3. **Owner handling.** The owner marks the corresponding relay as rejected and notifies the operator UI. The owner's next user-initiated relay addition for the same channel creates a fresh invitation, which the relay rejects again unless the rejection has been cleared. +3. **Owner handling.** The owner marks the corresponding relay as rejected and notifies the operator UI. The owner also sets the relay member's status to `GSMemLeft` so the UI treats the rejected relay identically to one that ran `/leave`. The owner's next user-initiated relay addition for the same channel creates a fresh invitation, which the relay rejects again unless the rejection has been cleared. 4. **Clear.** The relay operator runs `/relay allow ` to clear the rejection for the channel. After the next user-initiated relay addition, the relay accepts the invitation and rejoins as a relay. diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts index f8aa6e445d..d1b89ffe27 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -387,6 +387,20 @@ export namespace APIAddGroupRelays { } } +// Clear relay rejection for a channel (relay operator). +// Network usage: background. +export interface APIAllowRelayGroup { + groupId: number // int64 +} + +export namespace APIAllowRelayGroup { + export type Response = CR.RelayGroupAllowed | CR.ChatCmdError + + export function cmdString(self: APIAllowRelayGroup): string { + return '/_relay allow #' + self.groupId + } +} + // Update group profile. // Network usage: background. export interface APIUpdateGroupProfile { diff --git a/packages/simplex-chat-client/types/typescript/src/responses.ts b/packages/simplex-chat-client/types/typescript/src/responses.ts index e4284bf87e..0fcf0e6eca 100644 --- a/packages/simplex-chat-client/types/typescript/src/responses.ts +++ b/packages/simplex-chat-client/types/typescript/src/responses.ts @@ -32,6 +32,7 @@ export type ChatResponse = | CR.GroupRelays | CR.GroupRelaysAdded | CR.GroupRelaysAddFailed + | CR.RelayGroupAllowed | CR.GroupMembers | CR.GroupUpdated | CR.GroupsList @@ -89,6 +90,7 @@ export namespace CR { | "groupRelays" | "groupRelaysAdded" | "groupRelaysAddFailed" + | "relayGroupAllowed" | "groupMembers" | "groupUpdated" | "groupsList" @@ -293,6 +295,12 @@ export namespace CR { addRelayResults: T.AddRelayResult[] } + export interface RelayGroupAllowed extends Interface { + type: "relayGroupAllowed" + user: T.User + groupInfo: T.GroupInfo + } + export interface GroupMembers extends Interface { type: "groupMembers" user: T.User diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py index 9806388835..3847f44811 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py @@ -340,6 +340,18 @@ def APIAddGroupRelays_cmd_string(self: APIAddGroupRelays) -> str: APIAddGroupRelays_Response = CR.GroupRelaysAdded | CR.GroupRelaysAddFailed | CR.ChatCmdError +# Clear relay rejection for a channel (relay operator). +# Network usage: background. +class APIAllowRelayGroup(TypedDict): + groupId: int # int64 + + +def APIAllowRelayGroup_cmd_string(self: APIAllowRelayGroup) -> str: + return '/_relay allow #' + str(self['groupId']) + +APIAllowRelayGroup_Response = CR.RelayGroupAllowed | CR.ChatCmdError + + # Update group profile. # Network usage: background. class APIUpdateGroupProfile(TypedDict): diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_responses.py b/packages/simplex-chat-python/src/simplex_chat/types/_responses.py index 84d0f1c79f..e85de02c78 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_responses.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_responses.py @@ -149,6 +149,11 @@ class GroupRelaysAddFailed(TypedDict): user: "T.User" addRelayResults: list["T.AddRelayResult"] +class RelayGroupAllowed(TypedDict): + type: Literal["relayGroupAllowed"] + user: "T.User" + groupInfo: "T.GroupInfo" + class GroupMembers(TypedDict): type: Literal["groupMembers"] user: "T.User" @@ -329,6 +334,7 @@ ChatResponse = ( | GroupRelays | GroupRelaysAdded | GroupRelaysAddFailed + | RelayGroupAllowed | GroupMembers | GroupUpdated | GroupsList @@ -357,4 +363,4 @@ ChatResponse = ( | ApiChats ) -ChatResponse_Tag = Literal["acceptingContactRequest", "activeUser", "chatItemNotChanged", "chatItemReaction", "chatItemUpdated", "chatItemsDeleted", "chatRunning", "chatStarted", "chatStopped", "cmdOk", "chatCmdError", "connectionPlan", "contactAlreadyExists", "contactConnectionDeleted", "contactDeleted", "contactPrefsUpdated", "contactRequestRejected", "contactsList", "groupDeletedUser", "groupLink", "groupLinkCreated", "groupLinkDeleted", "groupCreated", "publicGroupCreated", "publicGroupCreationFailed", "groupRelays", "groupRelaysAdded", "groupRelaysAddFailed", "groupMembers", "groupUpdated", "groupsList", "invitation", "leftMemberUser", "memberAccepted", "membersBlockedForAllUser", "membersRoleUser", "newChatItems", "rcvFileAccepted", "rcvFileAcceptedSndCancelled", "rcvFileCancelled", "sentConfirmation", "sentGroupInvitation", "sentInvitation", "sndFileCancelled", "userAcceptedGroupSent", "userContactLink", "userContactLinkCreated", "userContactLinkDeleted", "userContactLinkUpdated", "userDeletedMembers", "userProfileUpdated", "userProfileNoChange", "usersList", "apiChats"] +ChatResponse_Tag = Literal["acceptingContactRequest", "activeUser", "chatItemNotChanged", "chatItemReaction", "chatItemUpdated", "chatItemsDeleted", "chatRunning", "chatStarted", "chatStopped", "cmdOk", "chatCmdError", "connectionPlan", "contactAlreadyExists", "contactConnectionDeleted", "contactDeleted", "contactPrefsUpdated", "contactRequestRejected", "contactsList", "groupDeletedUser", "groupLink", "groupLinkCreated", "groupLinkDeleted", "groupCreated", "publicGroupCreated", "publicGroupCreationFailed", "groupRelays", "groupRelaysAdded", "groupRelaysAddFailed", "relayGroupAllowed", "groupMembers", "groupUpdated", "groupsList", "invitation", "leftMemberUser", "memberAccepted", "membersBlockedForAllUser", "membersRoleUser", "newChatItems", "rcvFileAccepted", "rcvFileAcceptedSndCancelled", "rcvFileCancelled", "sentConfirmation", "sentGroupInvitation", "sentInvitation", "sndFileCancelled", "userAcceptedGroupSent", "userContactLink", "userContactLinkCreated", "userContactLinkDeleted", "userContactLinkUpdated", "userDeletedMembers", "userProfileUpdated", "userProfileNoChange", "usersList", "apiChats"] diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index f7511752c3..3d9e9d9777 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -772,12 +772,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise -> messageError "x.grp.relay.acpt: only owner can add relay" XGrpRelayReject reason | memberRole' membership == GROwner && isRelay m -> do - relay <- withStore $ \db -> do + -- GSMemLeft (not GSMemRejected): owner UI treats this identically to an explicit /leave from the relay; GSMemRejected has knocking-admission semantics. + (relay', m') <- withStore $ \db -> do relay <- getGroupRelayByGMId db (groupMemberId' m) - liftIO $ updateRelayStatusFromTo db relay RSInvited RSRejected + relay' <- liftIO $ updateRelayStatusFromTo db relay RSInvited RSRejected + liftIO $ updateGroupMemberStatus db userId m GSMemLeft + pure (relay', m {memberStatus = GSMemLeft}) -- complete the contact handshake so the relay receives INFO and cleans up its transient bookkeeping allowAgentConnectionAsync user conn' confId XOk - toView $ CEvtGroupRelayUpdated user gInfo m relay + toView $ CEvtGroupRelayUpdated user gInfo m' relay' toViewTE $ TERelayRejected user gInfo reason | otherwise -> messageError "x.grp.relay.reject: only owner should receive relay rejection" _ -> messageError "CONF from invited member must have x.grp.acpt" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index e7b70a60f3..137679ded6 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -9998,10 +9998,15 @@ testRelayRejectAfterLeave ps = -- alice's CONF handler emits TERelayRejected; the relay row flips to 'rejected'. alice <## "#team: relay rejected, reason: RRRRejoinRejected" - -- assert alice's fresh GroupRelay row is marked 'rejected' + -- assert alice's fresh GroupRelay row is marked 'rejected' and the relay + -- GroupMember is GSMemLeft so the owner UI treats it as gone aliceRelayStatuses <- withCCTransaction alice $ \db -> DB.query_ db "SELECT relay_status FROM group_relays" :: IO [Only T.Text] map (\(Only s) -> s) aliceRelayStatuses `shouldBe` ["rejected"] + aliceRelayMemStatuses <- withCCTransaction alice $ \db -> + DB.query_ db "SELECT member_status FROM group_members WHERE member_role = 'relay'" + :: IO [Only T.Text] + map (\(Only s) -> s) aliceRelayMemStatuses `shouldBe` ["left"] -- subscriber still doesn't receive after the failed re-invitation alice #> "#team after rejection" @@ -10084,6 +10089,12 @@ testRelayAllowAcceptsAgain ps = bob <# "#team_1> after allow" cath <# "#team> after allow [>>]" + -- after re-acceptance, the relay GroupMember is not in the rejected/left state + aliceRelayMemStatuses <- withCCTransaction alice $ \db -> + DB.query_ db "SELECT member_status FROM group_members WHERE member_role = 'relay'" + :: IO [Only T.Text] + map (\(Only s) -> s) aliceRelayMemStatuses `shouldBe` ["connected"] + testRelayDoesNotRejectUnrelatedChannel :: HasCallStack => TestParams -> IO () testRelayDoesNotRejectUnrelatedChannel ps = withNewTestChat ps "alice" aliceProfile $ \alice ->