This commit is contained in:
spaced4ndy
2026-05-15 13:55:29 +04:00
parent d4a586a496
commit 3ccd5de96c
26 changed files with 499 additions and 30 deletions
@@ -199,6 +199,9 @@ struct GroupMemberInfoView: View {
Label("Share relay address", systemImage: "square.and.arrow.up")
}
}
if groupRelay?.relayStatus == .rsRejected {
infoRow("Status", "rejected by relay operator")
}
} header: {
Text(channelMemberSectionHeader).foregroundColor(theme.colors.secondary)
} footer: {
@@ -486,8 +486,14 @@ func chatRelayDisplayName(_ relay: UserChatRelay) -> String {
func relayStatusIndicator(_ status: RelayStatus, connFailed: Bool = false, memberStatus: GroupMemberStatus? = nil) -> some View {
let removed = memberStatus.map { [.memLeft, .memRemoved, .memGroupDeleted].contains($0) } ?? false
let color: Color = connFailed || removed ? .red : (status == .rsActive ? .green : .yellow)
let text: LocalizedStringKey = connFailed ? "failed" : memberStatus == .memLeft ? "removed by operator" : removed ? "removed" : status.text
let isRejected = status == .rsRejected
let color: Color = connFailed || removed || isRejected ? .red : (status == .rsActive ? .green : .yellow)
let text: LocalizedStringKey =
connFailed ? "failed"
: memberStatus == .memLeft ? "removed by operator"
: isRejected ? "rejected"
: removed ? "removed"
: status.text
return HStack(spacing: 4) {
Circle()
.fill(color)
+2
View File
@@ -2640,6 +2640,7 @@ public enum RelayStatus: String, Decodable, Equatable, Hashable {
case rsAccepted = "accepted"
case rsActive = "active"
case rsInactive = "inactive"
case rsRejected = "rejected"
}
public struct RelayProfile: Codable, Equatable, Hashable {
@@ -2713,6 +2714,7 @@ extension RelayStatus {
case .rsAccepted: "accepted"
case .rsActive: "active"
case .rsInactive: "inactive"
case .rsRejected: "rejected"
}
}
}
+1 -1
View File
@@ -49,7 +49,7 @@ This document provides a structured mapping between product-level concepts, thei
| 28 | Chat Tags | [views/chat-list.md](views/chat-list.md) | [spec/state.md](../spec/state.md) | `ChatList/TagListView.swift`, `ChatListView.swift` | `Types.hs` (`ChatTag`), `Controller.hs` |
| 29 | User Address | [views/settings.md](views/settings.md) | [spec/api.md](../spec/api.md) | `UserSettings/UserAddressView.swift`, `Onboarding/AddressCreationCard.swift` | `Controller.hs` (`APICreateMyAddress`) |
| 30 | Member Support Chat | [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md) | `Group/MemberSupportView.swift`, `MemberAdmissionView.swift` | `Messages.hs` (`GroupChatScope`), `Controller.hs` |
| 31 | Channels (Relays) | [glossary.md](glossary.md), [views/chat.md](views/chat.md) | [spec/api.md](../spec/api.md), [spec/state.md](../spec/state.md), [spec/client/chat-view.md](../spec/client/chat-view.md), [spec/client/compose.md](../spec/client/compose.md) | `SimpleXChat/ChatTypes.swift` (`RelayStatus`, `RelayStatus.text`, `GroupRelay`, `GroupMemberRole.relay`, `CIDirection.channelRcv`, `GroupInfo.chatIconName`, `userCantSendReason`), `Shared/Views/Chat/ChatView.swift` (channel message rendering), `Shared/Views/Chat/ComposeMessage/ComposeView.swift` (`sendAsGroup`, Broadcast placeholder), `Shared/Views/Chat/Group/GroupChatInfoView.swift` (channel info adaptations), `Shared/Views/Chat/Group/ChannelMembersView.swift`, `Shared/Views/Chat/Group/ChannelRelaysView.swift`, `Shared/Model/AppAPITypes.swift` (`GroupShortLinkInfo`, `UserChatRelay`), `Shared/Model/SimpleXAPI.swift` (`apiNewPublicGroup`), `SimpleX SE/ShareAPI.swift` (channel `sendAsGroup`) | `Controller.hs` (`APINewPublicGroup`) |
| 31 | Channels (Relays) | [glossary.md](glossary.md), [views/chat.md](views/chat.md), [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md), [spec/state.md](../spec/state.md), [spec/client/chat-view.md](../spec/client/chat-view.md), [spec/client/compose.md](../spec/client/compose.md) | `SimpleXChat/ChatTypes.swift` (`RelayStatus` incl. `.rsRejected`, `RelayStatus.text`, `GroupRelay`, `GroupMemberRole.relay`, `GroupMemberStatus.memRejected`, `CIDirection.channelRcv`, `GroupInfo.chatIconName`, `userCantSendReason`), `Shared/Views/Chat/ChatView.swift` (channel message rendering), `Shared/Views/Chat/ComposeMessage/ComposeView.swift` (`sendAsGroup`, Broadcast placeholder), `Shared/Views/Chat/Group/GroupChatInfoView.swift` (channel info adaptations), `Shared/Views/Chat/Group/GroupMemberInfoView.swift` (rejected-status row), `Shared/Views/Chat/Group/ChannelMembersView.swift`, `Shared/Views/Chat/Group/ChannelRelaysView.swift`, `Shared/Views/NewChat/AddChannelView.swift` (`relayStatusIndicator` rejected branch), `Shared/Model/AppAPITypes.swift` (`GroupShortLinkInfo`, `UserChatRelay`), `Shared/Model/SimpleXAPI.swift` (`apiNewPublicGroup`), `SimpleX SE/ShareAPI.swift` (channel `sendAsGroup`) | `Controller.hs` (`APINewPublicGroup`, `APIAllowRelayGroup`, `XGrpRelayReject` CONF handler) |
---
+1
View File
@@ -221,6 +221,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 refused to rejoin this channel after a prior `/leave` — clearable only by the relay operator via `/relay allow <groupId>`. |
| 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
+1
View File
@@ -415,6 +415,7 @@ Event processing entry point: [`processReceivedMsg`](../Shared/Model/SimpleXAPI.
| `groupUpdated` | `user, toGroup: GroupInfo` | Group profile changed | [L1106](../Shared/Model/AppAPITypes.swift#L1106) |
| `groupLinkRelaysUpdated` | `user, groupInfo, groupLink, groupRelays: [GroupRelay]` | Channel relay configuration changed | [L1107](../Shared/Model/AppAPITypes.swift#L1107) |
| `groupMemberUpdated` | `user, groupInfo, fromMember, toMember` | Member info updated | [L1081](../Shared/Model/AppAPITypes.swift#L1081) |
| `groupRelayUpdated` | `user, groupInfo, member, groupRelay` | Owner-side: a relay's `relayStatus` and/or the member's status changed. Fires on `XGrpRelayReject` with `relayStatus = .rsRejected` and `member.memberStatus = .memRejected` — final until cleared by the relay operator's `/relay allow <groupId>` (no event emitted to the owner for that clear). | Controller.hs (`CEvtGroupRelayUpdated`) |
### File Transfer Events
+5 -1
View File
@@ -350,9 +350,13 @@ Groups use separate [`groupLinkButton()`](../../Shared/Views/Chat/Group/GroupCha
### [`channelRelaysButton()`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L639) → [`ChannelRelaysView`](../../Shared/Views/Chat/Group/ChannelRelaysView.swift)
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`).
- **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).
### Relay Refusal 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 <groupId>`, which has no owner-facing event.
### Leave Button Logic
Sole channel owner cannot leave (only delete). Guard: `members.filter({ $0.wrapped.memberRole == .owner && $0.wrapped.groupMemberId != groupInfo.membership.groupMemberId }).count > 0`.
+1 -1
View File
@@ -61,7 +61,7 @@
| Shared/Views/Chat/Group/ChannelRelaysView.swift | PC31 | Medium | Channel relay status list |
| Shared/Views/Chat/Group/AddGroupMembersView.swift | PC14, PC16 | Medium | Member invitation flow |
| Shared/Views/Chat/Group/GroupLinkView.swift | PC15 | Low | Group link creation/sharing |
| Shared/Views/Chat/Group/GroupMemberInfoView.swift | PC3, PC14, PC16, PC30 | Medium | Member details and role management |
| Shared/Views/Chat/Group/GroupMemberInfoView.swift | PC3, PC14, PC16, PC30, PC31 | Medium | Member details and role management; rejected-by-operator status row for relay members |
| Shared/Views/NewChat/NewChatView.swift | PC12, PC31 | High | New connection creation — onramp for all contacts and channels |
| Shared/Views/NewChat/QRCode.swift | PC12 | Low | QR code display/scanning utility |
| Shared/Views/Call/ActiveCallView.swift | PC17 | Medium | Call UI rendering |
+2 -2
View File
@@ -390,8 +390,8 @@ A **channel** is a group with `groupInfo.useRelays == true`. These types support
| Type | Kind | Description | Line |
|------|------|-------------|------|
| `RelayStatus` | `enum` | Relay lifecycle: `.rsNew`, `.rsInvited`, `.rsAccepted`, `.rsActive` | [L2506](../SimpleXChat/ChatTypes.swift#L2506) |
| `RelayStatus.text` | `extension` | Localized display text: New/Invited/Accepted/Active | [L2565](../SimpleXChat/ChatTypes.swift#L2565) |
| `RelayStatus` | `enum` | Relay lifecycle: `.rsNew`, `.rsInvited`, `.rsAccepted`, `.rsActive`, `.rsInactive`, `.rsRejected` | [L2506](../SimpleXChat/ChatTypes.swift#L2506) |
| `RelayStatus.text` | `extension` | Localized display text: New/Invited/Accepted/Active/Inactive/Rejected | [L2565](../SimpleXChat/ChatTypes.swift#L2565) |
| `GroupRelay` | `struct` | Relay instance for a group (ID, member ID, relay status). Fetched at runtime via `apiGetGroupRelays` (owner only) | [L2555](../SimpleXChat/ChatTypes.swift#L2555) |
| `UserChatRelay` | `struct` | User's chat relay configuration (ID, SMP address, name, domains, preset/tested/enabled/deleted flags) | [L2513](../SimpleXChat/ChatTypes.swift#L2513) |
+10
View File
@@ -72,6 +72,16 @@ When the owner adds a relay to an existing channel:
The announce is an optimisation. When it does not reach a subscriber — because the channel had no subscribers at announce time, because an older client or relay sits in the path, or because of a transient network failure — the subscriber reaches the same end state on the next channel open via its relay sync against the channel's link data.
### Relay refusal
**Trigger.** When a relay operator runs `APILeaveGroup` (`/leave #channel`) on a channel the relay serves, the relay's local `groups` row for the channel transitions `relay_own_status → 'rejected'` in the same transaction that flips its membership to `GSMemLeft`. The row is keyed by the channel's `relay_request_group_link` (a `ShortLinkContact`), which the relay learned when it received the original `x.grp.relay.inv`.
**Signal.** On the next `x.grp.relay.inv` from any owner for a channel link the relay has already marked `'rejected'`, the relay accepts the contact through the agent's normal async-accept path and sends `x.grp.relay.reject` over the owner-relay direct contact channel, then lets the eventual INFO event drive cleanup of its transient bookkeeping. The message is unsigned and is not part of the forwarded group message set. The payload carries a `RelayRejectionReason` — currently only `rejoin_refused`; older relays or future reasons fall through to `RRRUnknown text` for forward compatibility.
**Owner handling.** The owner's CONF handler validates that the sender is a relay member (`memberRole == GRRelay`) and that the receiver is the owner. It transitions the corresponding `GroupRelay.relayStatus` atomically `RSInvited → RSRejected`, marks the relay `GroupMember` `GSMemRejected`, and deletes the chat-layer connection. The transition is final on the owner side and is only cleared by the relay operator running `/relay allow <groupId>` (which transitions the relay's own row `'rejected' → 'inactive'` and emits no event back to the owner). The owner's subsequent user-initiated `addRelays` invocation creates a fresh `GroupRelay` row independent of the rejected one and proceeds normally — the relay's lookup will find no `'rejected'` row for the link.
**Limitations.** (a) An older owner client that does not recognise `x.grp.relay.reject` parses it as `XUnknown` and falls through to the CONF catch-all, logging a parse error and leaving the relay's `GroupRelay` permanently at `RSInvited` — the same UX as a relay that never responds. (b) An older relay binary continues to write `RSInactive` on leave and does not enforce refusal at `xGrpRelayInv`. In a mixed-version deployment where some relays are old, those relays accept fresh invitations after a leave while new-binary relays refuse — asymmetric behaviour that the operator can resolve by re-running `/leave` under the new binary.
### Subscriber connection
A subscriber joins a channel through the following flow:
+2
View File
@@ -132,6 +132,7 @@ library
Simplex.Chat.Store.Postgres.Migrations.M20260403_item_viewed
Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries
Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at
Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index
else
exposed-modules:
Simplex.Chat.Archive
@@ -286,6 +287,7 @@ library
Simplex.Chat.Store.SQLite.Migrations.M20260403_item_viewed
Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries
Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at
Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index
other-modules:
Paths_simplex_chat
hs-source-dirs:
+3
View File
@@ -407,6 +407,7 @@ data ChatCommand
| SetUserChatRelays [CLINewRelay]
| APITestChatRelay UserId ShortLinkContact
| TestChatRelay ShortLinkContact
| APIAllowRelayGroup {groupId :: GroupId}
| APIGetServerOperators
| APISetServerOperators (NonEmpty ServerOperator)
| SetServerOperators (NonEmpty ServerOperatorRoles)
@@ -735,6 +736,7 @@ data ChatResponse
| CRPublicGroupCreated {user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink, groupRelays :: [GroupRelay]}
| CRPublicGroupCreationFailed {user :: User, addRelayResults :: [AddRelayResult]}
| CRGroupRelays {user :: User, groupInfo :: GroupInfo, groupRelays :: [GroupRelay]}
| CRRelayGroupAllowed {user :: User, groupInfo :: GroupInfo}
| CRGroupRelaysAdded {user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink, groupRelays :: [GroupRelay]}
| CRGroupRelaysAddFailed {user :: User, addRelayResults :: [AddRelayResult]}
| CRGroupMembers {user :: User, group :: Group}
@@ -945,6 +947,7 @@ data ChatEvent
data TerminalEvent
= TEGroupLinkRejected {user :: User, groupInfo :: GroupInfo, groupRejectionReason :: GroupRejectionReason}
| TERelayRejected {user :: User, groupInfo :: GroupInfo, relayRejectionReason :: RelayRejectionReason}
| TERejectingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember, groupRejectionReason :: GroupRejectionReason}
| TENewMemberContact {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember}
| TEContactVerificationReset {user :: User, contact :: Contact}
+9
View File
@@ -1244,6 +1244,8 @@ processChatCommand vr nm = \case
let isOwner = memberRole' membership == GROwner
canDelete = isOwner || not (memberCurrent membership)
unless canDelete $ throwChatError $ CEGroupUserRole gInfo GROwner
when (relayOwnStatus gInfo == Just RSRejected) $
throwChatError $ CECommandError "cannot delete a rejected channel; run /_relay allow <groupId> first"
filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo
withGroupLock "deleteChat group" chatId $ do
deleteCIFiles user filesInfo
@@ -1578,6 +1580,9 @@ processChatCommand vr nm = \case
Right (Just (Just failure)) -> pure $ CRChatRelayTestResult user (Just relayProfile) (Just failure)
TestChatRelay address -> withUser $ \User {userId} ->
processChatCommand vr nm $ APITestChatRelay userId address
APIAllowRelayGroup groupId -> withUser $ \user -> do
gInfo' <- withStore $ \db -> allowRelayGroupAndSiblings db vr user groupId
pure $ CRRelayGroupAllowed user gInfo'
GetUserChatRelays -> withUser $ \user -> do
srvs <- withFastStore (`getUserServers` user)
liftIO $ CRUserServers user <$> groupByOperator (onlyRelays srvs)
@@ -2932,6 +2937,8 @@ processChatCommand vr nm = \case
deleteGroupLinkIfExists user gInfo'
-- member records are not deleted to keep history
withFastStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft
when (useRelays' gInfo && isRelay membership) $
withFastStore' $ \db -> updateRelayOwnStatus_ db gInfo RSRejected
pure $ CRLeftMemberUser user gInfo' {membership = membership {memberStatus = GSMemLeft}}
where
-- Relay leaving channel: create delivery job for cursor-based sending and async connection cleanup.
@@ -5032,6 +5039,8 @@ chatCommandP =
"/xftp" $> GetUserProtoServers (AProtocolType SPXFTP),
"/_relay test " *> (APITestChatRelay <$> A.decimal <* A.space <*> strP),
"/relay test " *> (TestChatRelay <$> strP),
"/_relay allow " *> (APIAllowRelayGroup <$> A.decimal),
"/relay allow " *> (APIAllowRelayGroup <$> A.decimal),
"/relays " *> (SetUserChatRelays <$> chatRelaysP),
"/relays" $> GetUserChatRelays,
"/_operators" $> APIGetServerOperators,
+26
View File
@@ -1059,6 +1059,32 @@ acceptRelayJoinRequestAsync
ownerMember' <- getGroupMemberById db vr user groupMemberId
pure (gInfo', ownerMember')
-- Asynchronous rejection of a relay invitation. Mirrors acceptGroupJoinSendRejectAsync:
-- creates a transient groups row tagged RSRejected (so the worker ignores it) with the
-- owner member in GSMemInvited so the eventual INFO arrival drives cleanup, then enqueues
-- agentAcceptContactAsync to send XGrpRelayReject through the agent's normal retry path.
acceptRelayJoinRequestRejectAsync
:: User
-> Int64
-> VersionRangeChat
-> GroupRelayInvitation
-> InvitationId
-> VersionRangeChat
-> Int64
-> RelayRejectionReason
-> CM ()
acceptRelayJoinRequestRejectAsync user uclId vr groupRelayInv invId reqChatVRange initialDelay reason = do
(_gInfo, ownerMember) <- withStore $ \db ->
createRelayRequestGroup db vr user groupRelayInv invId reqChatVRange initialDelay GSMemInvited RSRejected
let GroupMember {groupMemberId} = ownerMember
msg = XGrpRelayReject reason
subMode <- chatReadVar subscriptionMode
chatVR <- chatVersionRange
let chatV = chatVR `peerConnChatVersion` reqChatVRange
connIds <- agentAcceptContactAsync user False invId msg subMode PQSupportOff chatV
withStore' $ \db ->
createJoiningMemberConnection db user uclId connIds chatV reqChatVRange groupMemberId subMode
businessGroupProfile :: Profile -> GroupPreferences -> GroupProfile
businessGroupProfile Profile {displayName, fullName, shortDescr, image} groupPreferences =
GroupProfile {displayName, fullName, description = Nothing, shortDescr, image, publicGroup = Nothing, groupPreferences = Just groupPreferences, memberAdmission = Nothing}
+49 -13
View File
@@ -770,6 +770,18 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
withStore' $ \db -> setRelayLinkConfId db m confId relayLink
void $ getAgentConnShortLinkAsync user CFGetRelayDataAccept (Just conn') relayLink
| otherwise -> messageError "x.grp.relay.acpt: only owner can add relay"
XGrpRelayReject reason
| memberRole' membership == GROwner && isRelay m -> do
relay <- withStore $ \db -> do
relay <- getGroupRelayByGMId db (groupMemberId' m)
liftIO $ updateRelayStatusFromTo db relay RSInvited RSRejected
-- complete the contact handshake so the relay receives INFO and cleans
-- up its transient bookkeeping (the new INFO arm at GCHostMember +
-- RSRejected handles that), then tear down our chat-layer connection.
allowAgentConnectionAsync user conn' confId XOk
toView $ CEvtGroupRelayUpdated user gInfo m relay
toViewTE $ TERelayRejected user gInfo reason
| otherwise -> messageError "x.grp.relay.reject: only owner can receive relay rejection"
_ -> messageError "CONF from invited member must have x.grp.acpt"
GCHostMember ->
case chatMsgEvent of
@@ -812,14 +824,33 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
pure ()
| otherwise -> messageError "x.grp.mem.info: memberId is different from expected"
-- sent when connecting via group link
XInfo _ ->
-- TODO Keep rejected member to allow them to appeal against rejection.
when (memberStatus m == GSMemRejected) $ do
deleteMemberConnection' m True
withStore' $ \db -> deleteGroupMember db user m
XOk -> pure ()
XInfo _
| memberStatus m == GSMemRejected -> do
-- TODO Keep rejected member to allow them to appeal against rejection.
deleteMemberConnection' m True
withStore' $ \db -> deleteGroupMember db user m
| cleanupTransientRelayReject -> cleanupRelayRejectRow
| otherwise -> pure ()
XOk
| cleanupTransientRelayReject -> cleanupRelayRejectRow
| otherwise -> pure ()
_ -> messageError "INFO from member must have x.grp.mem.info, x.info or x.ok"
pure ()
where
-- Transient relay-reject row cleanup. The transient row is created with
-- RSRejected + GCHostMember owner. The persistent /leave-time row has the
-- same combination but its host connection is deleted by DJRelayRemoved
-- before any INFO can fire, so this filter only matches transients.
-- RSInactive is also matched to handle the case where APIAllowRelayGroup
-- flipped the transient row mid-flight.
cleanupTransientRelayReject =
memberCategory m == GCHostMember
&& maybe False (`elem` ([RSRejected, RSInactive] :: [RelayStatus])) (relayOwnStatus gInfo)
cleanupRelayRejectRow = do
deleteMemberConnection' m True
withStore' $ \db -> do
deleteGroupMember db user m
deleteGroup db user gInfo
CON _pqEnc -> unless (memberStatus m == GSMemRejected || memberStatus membership == GSMemRejected) $ do
-- TODO [knocking] send pending messages after accepting?
-- possible improvement: check for each pending message, requires keeping track of connection state
@@ -933,7 +964,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
if isUserGrpFwdRelay gInfo' && not (blockedByAdmin m)
then
let tasks
| relayOwnStatus gInfo' == Just RSInactive = filter relayRemovedNewTask newDeliveryTasks
| relayNotServing (relayOwnStatus gInfo') = filter relayRemovedNewTask newDeliveryTasks
| otherwise = newDeliveryTasks
in createDeliveryTasks gInfo' m' tasks
else pure False
@@ -1522,10 +1553,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
mem <- acceptGroupJoinSendRejectAsync user uclId gInfo invId chatVRange p xContactId_ rjctReason
toViewTE $ TERejectingGroupJoinRequestMember user gInfo mem rjctReason
xGrpRelayInv :: InvitationId -> VersionRangeChat -> GroupRelayInvitation -> CM ()
xGrpRelayInv invId chatVRange groupRelayInv = do
xGrpRelayInv invId chatVRange groupRelayInv@GroupRelayInvitation {groupLink} = do
refused <- withStore' $ \db -> isRelayGroupRefused db user groupLink
initialDelay <- asks $ initialInterval . relayRequestRetryInterval . config
(_gInfo, _ownerMember) <- withStore $ \db -> createRelayRequestGroup db vr user groupRelayInv invId chatVRange initialDelay
lift $ void $ getRelayRequestWorker True
if refused
then acceptRelayJoinRequestRejectAsync user uclId vr groupRelayInv invId chatVRange initialDelay RRRRejoinRefused
else do
(_gInfo, _ownerMember) <- withStore $ \db ->
createRelayRequestGroup db vr user groupRelayInv invId chatVRange initialDelay GSMemAccepted RSInvited
lift $ void $ getRelayRequestWorker True
xGrpRelayTest :: InvitationId -> VersionRangeChat -> ByteString -> CM ()
xGrpRelayTest invId chatVRange challenge = do
privKey_ <- withAgent $ \a -> getConnLinkPrivKey a (aConnId conn)
@@ -3129,7 +3165,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
unless (isUserGrpFwdRelay gInfo) $ deleteGroupConnections user gInfo False
withStore' $ \db -> do
updateGroupMemberStatus db userId membership GSMemRemoved
when (isJust $ relayOwnStatus gInfo) $ updateRelayOwnStatus_ db gInfo RSInactive
when (maybe False (/= RSRejected) (relayOwnStatus gInfo)) $ updateRelayOwnStatus_ db gInfo RSInactive
let membership' = membership {memberStatus = GSMemRemoved}
when withMessages $ deleteMessages gInfo membership' SMDSnd
deleteMemberItem msg gInfo RGEUserDeleted
@@ -3568,7 +3604,7 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do
processDeliveryTask task@MessageDeliveryTask {jobScope} =
case jobScopeImpliedSpec jobScope of
DJDeliveryJob _includePending
| relayOwnStatus gInfo == Just RSInactive -> do
| relayNotServing (relayOwnStatus gInfo) -> do
logWarn "delivery task worker: relay inactive"
withStore' $ \db -> setDeliveryTaskErrStatus db (deliveryTaskId task) "relay inactive"
| otherwise ->
@@ -3638,7 +3674,7 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do
processDeliveryJob job =
case jobScopeImpliedSpec jobScope of
DJDeliveryJob _includePending
| relayOwnStatus gInfo == Just RSInactive -> do
| relayNotServing (relayOwnStatus gInfo) -> do
logWarn "delivery job worker: relay inactive"
withStore' $ \db -> setDeliveryJobErrStatus db (deliveryJobId job) "relay inactive"
| otherwise -> do
+7
View File
@@ -444,6 +444,7 @@ data ChatMsgEvent (e :: MsgEncoding) where
XGrpRelayAcpt :: ShortLinkContact -> ChatMsgEvent 'Json
XGrpRelayTest :: ByteString -> Maybe ByteString -> ChatMsgEvent 'Json
XGrpRelayNew :: ShortLinkContact -> ChatMsgEvent 'Json
XGrpRelayReject :: RelayRejectionReason -> ChatMsgEvent 'Json
XGrpMemNew :: MemberInfo -> Maybe MsgScope -> ChatMsgEvent 'Json
XGrpMemIntro :: MemberInfo -> Maybe MemberRestrictions -> ChatMsgEvent 'Json
XGrpMemInv :: MemberId -> IntroInvitation -> ChatMsgEvent 'Json
@@ -989,6 +990,7 @@ data CMEventTag (e :: MsgEncoding) where
XGrpRelayAcpt_ :: CMEventTag 'Json
XGrpRelayTest_ :: CMEventTag 'Json
XGrpRelayNew_ :: CMEventTag 'Json
XGrpRelayReject_ :: CMEventTag 'Json
XGrpMemNew_ :: CMEventTag 'Json
XGrpMemIntro_ :: CMEventTag 'Json
XGrpMemInv_ :: CMEventTag 'Json
@@ -1047,6 +1049,7 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where
XGrpRelayAcpt_ -> "x.grp.relay.acpt"
XGrpRelayTest_ -> "x.grp.relay.test"
XGrpRelayNew_ -> "x.grp.relay.new"
XGrpRelayReject_ -> "x.grp.relay.reject"
XGrpMemNew_ -> "x.grp.mem.new"
XGrpMemIntro_ -> "x.grp.mem.intro"
XGrpMemInv_ -> "x.grp.mem.inv"
@@ -1106,6 +1109,7 @@ instance StrEncoding ACMEventTag where
"x.grp.relay.acpt" -> XGrpRelayAcpt_
"x.grp.relay.test" -> XGrpRelayTest_
"x.grp.relay.new" -> XGrpRelayNew_
"x.grp.relay.reject" -> XGrpRelayReject_
"x.grp.mem.new" -> XGrpMemNew_
"x.grp.mem.intro" -> XGrpMemIntro_
"x.grp.mem.inv" -> XGrpMemInv_
@@ -1161,6 +1165,7 @@ toCMEventTag msg = case msg of
XGrpRelayAcpt _ -> XGrpRelayAcpt_
XGrpRelayTest {} -> XGrpRelayTest_
XGrpRelayNew _ -> XGrpRelayNew_
XGrpRelayReject _ -> XGrpRelayReject_
XGrpMemNew {} -> XGrpMemNew_
XGrpMemIntro _ _ -> XGrpMemIntro_
XGrpMemInv _ _ -> XGrpMemInv_
@@ -1319,6 +1324,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do
sig_ <- fmap (\(B64UrlByteString s) -> s) <$> opt "signature"
pure $ XGrpRelayTest challenge sig_
XGrpRelayNew_ -> XGrpRelayNew <$> p "relayLink"
XGrpRelayReject_ -> XGrpRelayReject <$> p "reason"
XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" <*> opt "scope"
XGrpMemIntro_ -> XGrpMemIntro <$> p "memberInfo" <*> opt "memberRestrictions"
XGrpMemInv_ -> XGrpMemInv <$> p "memberId" <*> p "memberIntro"
@@ -1389,6 +1395,7 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en
("signature" .=? (B64UrlByteString <$> sig_))
["challenge" .= B64UrlByteString challenge]
XGrpRelayNew relayLink -> o ["relayLink" .= relayLink]
XGrpRelayReject reason -> o ["reason" .= reason]
XGrpMemNew memInfo scope -> o $ ("scope" .=? scope) ["memberInfo" .= memInfo]
XGrpMemIntro memInfo memRestrictions -> o $ ("memberRestrictions" .=? memRestrictions) ["memberInfo" .= memInfo]
XGrpMemInv memId memIntro -> o ["memberId" .= memId, "memberIntro" .= memIntro]
+46 -6
View File
@@ -95,6 +95,8 @@ module Simplex.Chat.Store.Groups
createRelayRequestGroup,
updateRelayOwnStatusFromTo,
updateRelayOwnStatus_,
isRelayGroupRefused,
allowRelayGroupAndSiblings,
getRelayServedGroups,
getRelayInactiveGroups,
createNewContactMemberAsync,
@@ -1523,8 +1525,8 @@ setGroupInProgressDone db GroupInfo {groupId} = do
"UPDATE groups SET creating_in_progress = 0, updated_at = ? WHERE group_id = ?"
(currentTs, groupId)
createRelayRequestGroup :: DB.Connection -> VersionRangeChat -> User -> GroupRelayInvitation -> InvitationId -> VersionRangeChat -> Int64 -> ExceptT StoreError IO (GroupInfo, GroupMember)
createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMember, fromMemberProfile, relayMemberId, groupLink} invId reqChatVRange initialDelay = do
createRelayRequestGroup :: DB.Connection -> VersionRangeChat -> User -> GroupRelayInvitation -> InvitationId -> VersionRangeChat -> Int64 -> GroupMemberStatus -> RelayStatus -> ExceptT StoreError IO (GroupInfo, GroupMember)
createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMember, fromMemberProfile, relayMemberId, groupLink} invId reqChatVRange initialDelay ownerStatus relayStatus = do
currentTs <- liftIO getCurrentTime
-- Create group with placeholder profile
let Profile {displayName = fromMemberLDN} = fromMemberProfile
@@ -1538,10 +1540,10 @@ createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMembe
groupPreferences = Nothing,
memberAdmission = Nothing
}
(groupId, _groupLDN) <- createGroup_ db userId placeholderProfile Nothing Nothing True (Just RSInvited) Nothing currentTs
(groupId, _groupLDN) <- createGroup_ db userId placeholderProfile Nothing Nothing True (Just relayStatus) Nothing currentTs
-- Store relay request data for recovery
liftIO $ setRelayRequestData_ groupId currentTs
ownerMemberId <- insertOwner_ currentTs groupId
ownerMemberId <- insertOwner_ currentTs groupId ownerStatus
let relayMember = MemberIdRole relayMemberId GRRelay
-- TODO [member keys] should relays use member keys?
_membership <- createContactMemberInv_ db user groupId (Just ownerMemberId) user relayMember GCUserMember GSMemAccepted IBUnknown Nothing Nothing currentTs vr
@@ -1563,7 +1565,7 @@ createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMembe
WHERE group_id = ?
|]
(Binary invId, groupLink, minVersion reqChatVRange, maxVersion reqChatVRange, initialDelay, currentTs, groupId)
insertOwner_ currentTs groupId = do
insertOwner_ currentTs groupId ownerStatus_ = do
let MemberIdRole {memberId, memberRole} = fromMember
VersionRange minV maxV = reqChatVRange
(localDisplayName, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs
@@ -1578,7 +1580,7 @@ createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMembe
peer_chat_min_version, peer_chat_max_version)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|]
( (groupId, indexInGroup, memberId, memberRole, GCHostMember, GSMemAccepted)
( (groupId, indexInGroup, memberId, memberRole, GCHostMember, ownerStatus_)
:. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs)
:. (minV, maxV)
)
@@ -1596,6 +1598,44 @@ updateRelayOwnStatus_ db GroupInfo {groupId} relayStatus = do
let inactiveAt_ = if relayStatus == RSInactive then Just currentTs else Nothing
DB.execute db "UPDATE groups SET relay_own_status = ?, relay_inactive_at = ?, updated_at = ? WHERE group_id = ?" (relayStatus, inactiveAt_, currentTs, groupId)
-- Atomically flip every RSRejected row sharing the targeted group's
-- relay_request_group_link to RSInactive. Returns the refreshed GroupInfo
-- for the targeted groupId (whether it was flipped or not). The subquery
-- resolves the link in the same UPDATE statement so there is no
-- read-then-write race with concurrent xGrpRelayInv handlers.
allowRelayGroupAndSiblings :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO GroupInfo
allowRelayGroupAndSiblings db vr user@User {userId} groupId = do
currentTs <- liftIO getCurrentTime
liftIO $
DB.execute
db
[sql|
UPDATE groups
SET relay_own_status = ?, relay_inactive_at = ?, updated_at = ?
WHERE user_id = ?
AND relay_request_group_link = (SELECT relay_request_group_link FROM groups WHERE group_id = ?)
AND relay_own_status = ?
|]
(RSInactive, currentTs, currentTs, userId, groupId, RSRejected)
getGroupInfo db vr user groupId
isRelayGroupRefused :: DB.Connection -> User -> ShortLinkContact -> IO Bool
isRelayGroupRefused db User {userId} groupLink =
fromMaybe False <$> maybeFirstRow fromOnly (
DB.query
db
[sql|
SELECT EXISTS (
SELECT 1 FROM groups
WHERE user_id = ?
AND relay_request_group_link = ?
AND relay_own_status = ?
LIMIT 1
)
|]
(userId, groupLink, RSRejected)
)
getRelayServedGroups :: DB.Connection -> VersionRangeChat -> User -> IO [GroupInfo]
getRelayServedGroups db vr User {userId, userContactId} = do
map (toGroupInfo vr userContactId [])
@@ -30,6 +30,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260222_chat_relays
import Simplex.Chat.Store.Postgres.Migrations.M20260403_item_viewed
import Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries
import Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at
import Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Text, Maybe Text)]
@@ -59,7 +60,8 @@ schemaMigrations =
("20260222_chat_relays", m20260222_chat_relays, Just down_m20260222_chat_relays),
("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed),
("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries),
("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at)
("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at),
("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index)
]
-- | The list of migrations in ascending order by date
@@ -0,0 +1,21 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index where
import Data.Text (Text)
import Text.RawString.QQ (r)
m20260514_relay_request_group_link_index :: Text
m20260514_relay_request_group_link_index =
[r|
CREATE INDEX idx_groups_relay_request_group_link
ON groups(user_id, relay_request_group_link)
WHERE relay_request_group_link IS NOT NULL;
|]
down_m20260514_relay_request_group_link_index :: Text
down_m20260514_relay_request_group_link_index =
[r|
DROP INDEX idx_groups_relay_request_group_link;
|]
+3 -1
View File
@@ -153,6 +153,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260222_chat_relays
import Simplex.Chat.Store.SQLite.Migrations.M20260403_item_viewed
import Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries
import Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at
import Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@@ -305,7 +306,8 @@ schemaMigrations =
("20260222_chat_relays", m20260222_chat_relays, Just down_m20260222_chat_relays),
("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed),
("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries),
("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at)
("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at),
("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index)
]
-- | The list of migrations in ascending order by date
@@ -0,0 +1,20 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20260514_relay_request_group_link_index :: Query
m20260514_relay_request_group_link_index =
[sql|
CREATE INDEX idx_groups_relay_request_group_link
ON groups(user_id, relay_request_group_link)
WHERE relay_request_group_link IS NOT NULL;
|]
down_m20260514_relay_request_group_link_index :: Query
down_m20260514_relay_request_group_link_index =
[sql|
DROP INDEX idx_groups_relay_request_group_link;
|]
@@ -1295,6 +1295,12 @@ CREATE INDEX idx_chat_items_groups_item_viewed ON chat_items(
item_viewed,
item_ts
);
CREATE INDEX idx_groups_relay_request_group_link
ON groups(
user_id,
relay_request_group_link
)
WHERE relay_request_group_link IS NOT NULL;
CREATE TRIGGER on_group_members_insert_update_summary
AFTER INSERT ON group_members
FOR EACH ROW
+24
View File
@@ -916,6 +916,30 @@ instance ToJSON GroupRejectionReason where
toJSON = strToJSON
toEncoding = strToJEncoding
data RelayRejectionReason
= RRRRejoinRefused
| RRRUnknown {text :: Text}
deriving (Eq, Show)
instance FromField RelayRejectionReason where fromField = blobFieldDecoder strDecode
instance ToField RelayRejectionReason where toField = toField . strEncode
instance StrEncoding RelayRejectionReason where
strEncode = \case
RRRRejoinRefused -> "rejoin_refused"
RRRUnknown text -> encodeUtf8 text
strP =
"rejoin_refused" $> RRRRejoinRefused
<|> RRRUnknown . safeDecodeUtf8 <$> A.takeByteString
instance FromJSON RelayRejectionReason where
parseJSON = strParseJSON "RelayRejectionReason"
instance ToJSON RelayRejectionReason where
toJSON = strToJSON
toEncoding = strToJEncoding
data MemberIdRole = MemberIdRole
{ memberId :: MemberId,
memberRole :: GroupMemberRole
+13
View File
@@ -84,6 +84,7 @@ data RelayStatus
| RSAccepted
| RSActive
| RSInactive
| RSRejected
deriving (Eq, Show)
relayStatusText :: RelayStatus -> Text
@@ -93,6 +94,7 @@ relayStatusText = \case
RSAccepted -> "accepted"
RSActive -> "active"
RSInactive -> "inactive"
RSRejected -> "rejected"
instance TextEncoding RelayStatus where
textEncode = \case
@@ -101,12 +103,14 @@ instance TextEncoding RelayStatus where
RSAccepted -> "accepted"
RSActive -> "active"
RSInactive -> "inactive"
RSRejected -> "rejected"
textDecode = \case
"new" -> Just RSNew
"invited" -> Just RSInvited
"accepted" -> Just RSAccepted
"active" -> Just RSActive
"inactive" -> Just RSInactive
"rejected" -> Just RSRejected
_ -> Nothing
instance FromField RelayStatus where fromField = fromTextField_ textDecode
@@ -115,6 +119,15 @@ instance ToField RelayStatus where toField = toField . textEncode
$(JQ.deriveJSON (enumJSON $ dropPrefix "RS") ''RelayStatus)
-- True for relay-own-status values that mean "relay not serving this group".
-- Both RSInactive (relay removed or stopped) and RSRejected (relay refused to rejoin)
-- block normal message delivery; DJRelayRemoved is handled in a status-independent branch.
relayNotServing :: Maybe RelayStatus -> Bool
relayNotServing = \case
Just RSInactive -> True
Just RSRejected -> True
_ -> False
data MsgSigStatus = MSSVerified | MSSSignedNoKey
deriving (Eq, Show)
+11 -2
View File
@@ -184,6 +184,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte
CRGroupRelays u g relays -> ttyUser u $ viewGroupRelays g relays
CRGroupRelaysAdded u g _groupLink relays -> ttyUser u $ viewGroupRelays g relays
CRGroupRelaysAddFailed u results -> ttyUser u $ viewGroupRelaysAddFailed results
CRRelayGroupAllowed u g -> ttyUser u [ttyFullGroup g <> ": relay refusal cleared"]
CRGroupMembers u g -> ttyUser u $ viewGroupMembers g
CRMemberSupportChats u g ms -> ttyUser u $ viewMemberSupportChats g ms
-- CRGroupConversationsArchived u _g _conversations -> ttyUser u []
@@ -541,6 +542,7 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView}
CEvtTerminalEvent te -> case te of
TERejectingGroupJoinRequestMember _ g m reason -> [ttyFullMember m <> ": rejecting request to join group " <> ttyGroup' g <> ", reason: " <> sShow reason]
TEGroupLinkRejected u g reason -> ttyUser u [ttyGroup' g <> ": join rejected, reason: " <> sShow reason]
TERelayRejected u g reason -> ttyUser u [ttyGroup' g <> ": relay rejected, reason: " <> sShow reason]
TENewMemberContact u _ g m -> ttyUser u ["contact for member " <> ttyGroup' g <> " " <> ttyMember m <> " is created"]
TEContactVerificationReset u ct -> ttyUser u $ viewContactVerificationReset ct
TEGroupMemberVerificationReset u g m -> ttyUser u $ viewGroupMemberVerificationReset g m
@@ -1435,11 +1437,18 @@ viewGroupsList gs = map groupSS $ sortOn ldn_ gs
where
ldn_ :: GroupInfo -> Text
ldn_ GroupInfo {localDisplayName} = T.toLower localDisplayName
groupSS g@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}, groupSummary = GroupSummary {currentMembers}} =
groupSS g@GroupInfo { membership
, chatSettings = ChatSettings {enableNtfs}
, groupSummary = GroupSummary {currentMembers}
, relayOwnStatus
} =
case memberStatus membership of
GSMemInvited -> groupInvitation' g
s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s <> alias g
s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s <> rejectionSuffix <> alias g
where
rejectionSuffix = case relayOwnStatus of
Just RSRejected -> " [rejected]"
_ -> ""
viewMemberStatus = \case
GSMemRejected -> delete "you are rejected"
GSMemRemoved -> delete "you are removed"
+222
View File
@@ -272,6 +272,12 @@ chatGroupTests = do
it "should add relay to existing channel" testChannelAddRelay
it "should remove relay from channel" testChannelRemoveRelay
it "should remove left relay from channel" testChannelRemoveLeftRelay
describe "relay refusal" $ do
it "relay refuses fresh invitation after leaving the same channel" testRelayRefuseAfterLeave
it "operator allow clears rejection and relay accepts again" testRelayAllowAcceptsAgain
it "rejection on channel A does not affect unrelated channel B" testRelayDoesNotRefuseUnrelatedChannel
it "concurrent fresh invitations both refused" testRelayRefuseRaceConcurrentInvitations
it "deleting a rejected channel is blocked until operator allow" testRelayDeleteRejectedBlocked
describe "channel message operations" $ do
it "should update channel message" testChannelMessageUpdate
it "should delete channel message" testChannelMessageDelete
@@ -9920,6 +9926,222 @@ testChannelRemoveLeftRelay ps =
DB.query_ db "SELECT local_display_name FROM group_members" :: IO [Only T.Text]
danMembers2 `shouldMatchList` [Only "dan", Only "alice"]
-- | Read the relay_own_status column from a relay's groups table by group_id.
queryRelayOwnStatus :: TestCC -> Int64 -> IO (Maybe T.Text)
queryRelayOwnStatus cc gId = do
rows <- withCCTransaction cc $ \db ->
DB.query db "SELECT relay_own_status FROM groups WHERE group_id = ?" (Only gId)
:: IO [Only (Maybe T.Text)]
pure $ case rows of
[Only s] -> s
_ -> Nothing
-- | All (group_id, relay_own_status) rows on a relay where relay_own_status is set.
listRelayOwnStatuses :: TestCC -> IO [(Int64, T.Text)]
listRelayOwnStatuses cc =
withCCTransaction cc $ \db ->
DB.query_
db
"SELECT group_id, relay_own_status FROM groups WHERE relay_own_status IS NOT NULL ORDER BY group_id"
:: IO [(Int64, T.Text)]
-- | Directly set relay_own_status on a relay's groups row (test surgery).
setRelayOwnStatus :: TestCC -> Int64 -> T.Text -> IO ()
setRelayOwnStatus cc gId status =
withCCTransaction cc $ \db ->
DB.execute db "UPDATE groups SET relay_own_status = ? WHERE group_id = ?" (status, gId)
-- | Poll for a relay's row count with non-NULL relay_own_status to reach
-- `expected` (e.g. after INFO cleanup removed a transient row). Up to 20 s.
waitForRelayGroupCount :: TestCC -> Int -> IO Int
waitForRelayGroupCount cc expected = loop (40 :: Int)
where
countRows = do
rows <- withCCTransaction cc $ \db ->
DB.query_ db "SELECT COUNT(*) FROM groups WHERE relay_own_status IS NOT NULL" :: IO [Only Int]
pure $ case rows of
[Only n] -> n
_ -> 0
loop 0 = countRows
loop n = do
c <- countRows
if c == expected
then pure c
else threadDelay 500000 >> loop (n - 1)
testRelayRefuseAfterLeave :: HasCallStack => TestParams -> IO ()
testRelayRefuseAfterLeave ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do
_ <- prepareChannel1Relay "team" alice bob
threadDelay 100000
-- relay leaves the channel
bob ##> "/leave #team"
bob <## "#team: you left the group"
bob <## "use /d #team to delete the group"
alice <## "#team: bob left the group (signed)"
threadDelay 100000
bobLeaveStatus <- queryRelayOwnStatus bob 1
bobLeaveStatus `shouldBe` Just "rejected"
-- owner removes the (now-left) relay member; cascade clears alice's group_relays row
alice ##> "/rm #team bob"
alice <## "#team: you removed bob from the group (signed)"
threadDelay 100000
-- owner re-adds bob as relay
alice ##> "/_add relays #1 1"
alice <## "#team: group relays:"
alice .<##. (" - relay id", ": invited")
-- bob's xGrpRelayInv finds the 'rejected' row for this link and sends XGrpRelayReject.
-- alice's CONF handler emits TERelayRejected; the relay row flips to 'rejected'.
alice <## "#team: relay rejected, reason: RRRRejoinRefused"
-- assert alice's fresh GroupRelay row is marked 'rejected'
aliceRelayStatuses <- withCCTransaction alice $ \db ->
DB.query_ db "SELECT relay_status FROM group_relays" :: IO [Only T.Text]
map (\(Only s) -> s) aliceRelayStatuses `shouldBe` ["rejected"]
-- bob's transient row was created with relay_own_status='rejected';
-- after INFO arrives the cleanup arm deletes it. Original row 1 remains rejected.
_ <- waitForRelayGroupCount bob 1
finalStatuses <- listRelayOwnStatuses bob
finalStatuses `shouldBe` [(1, "rejected")]
testRelayAllowAcceptsAgain :: HasCallStack => TestParams -> IO ()
testRelayAllowAcceptsAgain ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do
_ <- prepareChannel1Relay "team" alice bob
threadDelay 100000
bob ##> "/leave #team"
bob <## "#team: you left the group"
bob <## "use /d #team to delete the group"
alice <## "#team: bob left the group (signed)"
threadDelay 100000
-- /_relay allow flips bob's row from 'rejected' to 'inactive'
bob ##> "/_relay allow 1"
bob <## "#team: relay refusal cleared"
bobClearStatus <- queryRelayOwnStatus bob 1
bobClearStatus `shouldBe` Just "inactive"
-- owner can now re-add and bob accepts as relay
alice ##> "/rm #team bob"
alice <## "#team: you removed bob from the group (signed)"
threadDelay 100000
alice ##> "/_add relays #1 1"
concurrentlyN_
[ do
alice <## "#team: group relays:"
alice .<##. (" - relay id", ": invited")
alice <## "#team: group link relays updated, current relays:"
alice .<##. (" - relay id", ": active")
alice <## "group link:"
void $ getTermLine alice,
bob <## "#team_1: you joined the group as relay"
]
testRelayDoesNotRefuseUnrelatedChannel :: HasCallStack => TestParams -> IO ()
testRelayDoesNotRefuseUnrelatedChannel ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do
_ <- prepareChannel1Relay "teama" alice bob
threadDelay 100000
bob ##> "/leave #teama"
bob <## "#teama: you left the group"
bob <## "use /d #teama to delete the group"
alice <## "#teama: bob left the group (signed)"
threadDelay 100000
bobAStatus <- queryRelayOwnStatus bob 1
bobAStatus `shouldBe` Just "rejected"
-- alice creates a second channel reusing the same bob relay config.
-- bob's xGrpRelayInv for teamb's link finds no rejection and accepts normally.
_ <- prepareChannel' 2 "teamb" alice bob
threadDelay 100000
bobBStatus <- queryRelayOwnStatus bob 2
bobBStatus `shouldNotBe` Just "rejected"
bobBStatus `shouldNotBe` Nothing
testRelayRefuseRaceConcurrentInvitations :: HasCallStack => TestParams -> IO ()
testRelayRefuseRaceConcurrentInvitations ps =
-- After rejection, multiple sequential re-invitations must all refuse with
-- consistent state (each transient row created with RSRejected and cleaned
-- up by its own INFO).
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do
_ <- prepareChannel1Relay "team" alice bob
threadDelay 100000
bob ##> "/leave #team"
bob <## "#team: you left the group"
bob <## "use /d #team to delete the group"
alice <## "#team: bob left the group (signed)"
threadDelay 100000
-- first refusal
alice ##> "/rm #team bob"
alice .<##. ("#team: you removed bob from the group", "")
threadDelay 100000
alice ##> "/_add relays #1 1"
alice <## "#team: group relays:"
alice .<##. (" - relay id", ": invited")
alice <## "#team: relay rejected, reason: RRRRejoinRefused"
void $ waitForRelayGroupCount bob 1
-- second refusal
alice ##> "/rm #team bob"
alice .<##. ("#team: you removed bob from the group", "")
threadDelay 100000
alice ##> "/_add relays #1 1"
alice <## "#team: group relays:"
alice .<##. (" - relay id", ": invited")
alice <## "#team: relay rejected, reason: RRRRejoinRefused"
_ <- waitForRelayGroupCount bob 1
finalStatuses <- listRelayOwnStatuses bob
finalStatuses `shouldBe` [(1, "rejected")]
testRelayDeleteRejectedBlocked :: HasCallStack => TestParams -> IO ()
testRelayDeleteRejectedBlocked ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do
_ <- prepareChannel1Relay "team" alice bob
threadDelay 100000
bob ##> "/leave #team"
bob <## "#team: you left the group"
bob <## "use /d #team to delete the group"
alice <## "#team: bob left the group (signed)"
threadDelay 100000
bobStatus <- queryRelayOwnStatus bob 1
bobStatus `shouldBe` Just "rejected"
bob ##> "/d #team"
bob <## "bad chat command: cannot delete a rejected channel; run /_relay allow <groupId> first"
stillRejected <- queryRelayOwnStatus bob 1
stillRejected `shouldBe` Just "rejected"
bob ##> "/_relay allow 1"
bob <## "#team: relay refusal cleared"
bobInactive <- queryRelayOwnStatus bob 1
bobInactive `shouldBe` Just "inactive"
bob ##> "/d #team"
bob <## "#team: you deleted the group"
testChannelCreateDeletedRelay :: HasCallStack => TestParams -> IO ()
testChannelCreateDeletedRelay ps =
withNewTestChat ps "alice" aliceProfile $ \alice -> do