* core: channel messages (WIP) * do not include member ID when quoting channel messages * query plans * reduce duplication * refactor * refactor plan * refactor 2 * all tests * remove plan * refactor 3 * refactor 4 * refactor 5 * refactor 6 * plans * plans to imrove test coverage and fix bugs * update plan * update plan * bug fixes (wip) * new plan * fixes wip * more tests * comment, fix lint * restore comment * restore comments * rename param * move type * simplify * comment * fix stale state * refactor * less diff * simplify * less diff * refactor --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
17 KiB
Plan: Fix Channel Message Delivery Architecture
Table of Contents
- Context
- Executive Summary
- Issue 1: Eliminate memberForChannel/memberIdForChannel
- Issue 2: groupMsgReaction required GroupMember
- Issue 3: Fix groupMessageUpdate lookup
- Issue 4: DeliveryTaskContext type
- Issue 5: Fix testChannelReactionAttribution
- Issue 6: Fix testChannelUpdateFallbackSendAsGroup comment
- Other: sendAsGroup parameter ordering
- Verification
Context
The current implementation on ep/channel-messages-2 determines delivery context (whether to forward messages as channel or as member) using isChannelOwner — inferring from the sender's role whether they're the channel owner. This is architecturally wrong: the delivery context should be determined from the item's direction (CIChannelRcv vs CIGroupRcv), not from who sent it. The f/msg-from-channel branch has the correct approach.
Executive Summary
7 changes across 7 files:
- Delivery.hs — Add
DeliveryTaskContexttype, updateNewMessageDeliveryTaskonly (MessageDeliveryTaskunchanged) - Subscriber.hs — Eliminate
isChannelOwner/memberForChannel/memberIdForChannel; all processing functions returnMaybe DeliveryTaskContext; determinesentAsGroupfrom item direction;groupMsgReactiontakes requiredGroupMember; addwithAuthorin forwarded handler - Store/Delivery.hs — Update SQL row mapping for
taskContext - Commands.hs — Reorder
sendAsGroupparam inAPIForwardChatItems - Store/Messages.hs — Reorder
showGroupAsSenderparam increateNewSndChatItem - Internal.hs — Reorder
showGroupAsSenderparam insaveSndChatItems,prepareGroupMsg - Tests — Fix reaction test comment/expectations, fix update fallback test comment
Issue 1: Eliminate memberForChannel/memberIdForChannel
File: src/Simplex/Chat/Library/Subscriber.hs lines 935-937, 939-991
Problem: isChannelOwner, memberForChannel, memberIdForChannel computed at lines 935-937 and passed to processing functions. This pre-infers delivery context from member role.
Fix: Remove these three bindings entirely. Always pass (Just m'') to functions that take Maybe GroupMember. Functions determine sentAsGroup from item direction internally.
Direct handler changes (lines 939-991):
-- BEFORE:
let isChannelOwner = useRelays' gInfo' && memberRole' m'' == GROwner
memberForChannel = if isChannelOwner then Nothing else Just m''
memberIdForChannel = memberId' <$> memberForChannel
(deliveryJobScope_, showGroupAsSender') <- case event of
...
forM deliveryJobScope_ $ \jobScope ->
pure $ NewMessageDeliveryTask {messageId = msgId, jobScope, showGroupAsSender = showGroupAsSender'}
-- AFTER:
deliveryTaskContext_ <- case event of
XMsgNew mc -> ... -- returns Maybe DeliveryTaskContext
XMsgFileDescr ... -> groupMessageFileDescription gInfo' (Just m'') sharedMsgId fileDescr
XMsgUpdate ... -> memberCanSend m'' msgScope Nothing $ groupMessageUpdate gInfo' (Just m'') sharedMsgId ...
XMsgDel ... -> groupMessageDelete gInfo' (Just m'') sharedMsgId ...
XMsgReact ... -> groupMsgReaction gInfo' m'' sharedMsgId ... -- required member
XFileCancel sharedMsgId -> xFileCancelGroup gInfo' (Just m'') sharedMsgId
...other events -> Just <$> memberEventDeliveryContext m'' / Nothing
forM deliveryTaskContext_ $ \taskContext ->
pure $ NewMessageDeliveryTask {messageId = msgId, taskContext}
Processing function signature changes:
groupMessageFileDescription :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> FileDescr -> CM (Maybe DeliveryTaskContext)— drop bothMaybe MemberIdparams, passMaybe GroupMember, determinesentAsGroupfromchatDirof found itemgroupMessageUpdate :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> ... -> Maybe Bool -> CM (Maybe DeliveryTaskContext)— dropsenderGMId_paramgroupMessageDelete :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> ... -> CM (Maybe DeliveryTaskContext)— dropsenderGMId_param; fixfindOwnerCIdual-lookup (lines 2028-2035) same as Issue 3: whenm_ = Nothingsearch withNothing, whenm_ = Just muse member lookup directlyxFileCancelGroup :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> CM (Maybe DeliveryTaskContext)— drop bothMaybe MemberIdparams
validSender simplification: Remove second Maybe MemberId parameter. With (Just m'') always passed, validation is just:
validSender :: Maybe MemberId -> CIDirection 'CTGroup 'MDRcv -> Bool
validSender (Just mId) (CIGroupRcv m) = sameMemberId mId m
validSender Nothing CIChannelRcv = True
validSender _ _ = False
isChannelDir helper remains as-is (line 1870-1872) — used to derive sentAsGroup from item's chatDir.
memberCanSend (line 1436): Generic signature a -> CM a -> CM a — no change needed. Default values at call sites change from (Nothing, False) to Nothing.
memberCanSend' (line 1448): Return type changes from CM (Maybe DeliveryJobScope) to CM (Maybe DeliveryTaskContext). Used in forwarded handler (lines 3153, 3159).
Issue 2: groupMsgReaction required GroupMember
File: src/Simplex/Chat/Library/Subscriber.hs line 1814
Problem: groupMsgReaction :: GroupInfo -> Maybe GroupMember -> ... allows Nothing, uses fromMaybe membership m_ fallback.
Fix: Change to required GroupMember:
groupMsgReaction :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> MsgReaction -> Bool -> RcvMessage -> UTCTime -> CM (Maybe DeliveryTaskContext)
- No
reactorbinding needed — usemdirectly (eliminatesfromMaybe membership m_fallback) ciDir = CIGroupRcv (Just m)(reactions always attributed to member)- Always return
sentAsGroup = False— reactions are never from channel - Return type:
Maybe DeliveryTaskContext(not tuple)
Direct handler call site (line 958-960):
XMsgReact sharedMsgId memberId scope_ reaction add ->
groupMsgReaction gInfo' m'' sharedMsgId memberId scope_ reaction add msg brokerTs
Forwarded handler call site (line 3162-3163):
XMsgReact sharedMsgId memId_ scope_ reaction add ->
withAuthor XMsgReact_ $ \author -> groupMsgReaction gInfo author sharedMsgId memId_ scope_ reaction add rcvMsg msgTs
Issue 3: Fix groupMessageUpdate lookup
File: src/Simplex/Chat/Library/Subscriber.hs lines 1973-1994
Problem: Dual-lookup with catchError tries Nothing first, then falls back to senderGMId_. This is wrong — the asGroup_ flag from XMsgUpdate should drive the search.
Fix: Use asGroup_ (the wire flag) to determine search strategy. No senderGMId_ parameter needed:
updateRcvChatItem = do
(cci, scopeInfo) <- withStore $ \db -> do
cci <- case m_ of
Just m -> getGroupMemberCIBySharedMsgId db user gInfo (memberId' m) sharedMsgId
Nothing -> getGroupChatItemBySharedMsgId db user gInfo Nothing sharedMsgId
(cci,) <$> getGroupChatScopeInfoForItem db vr user gInfo (cChatItemId cci)
When m_ = Nothing (channel owner as channel), search with Nothing group_member_id → finds channel items.
When m_ = Just m (attributed member message), search with member's memberId → finds member items.
The isSender check also simplifies — just check m_ matches the found item's member.
Fallback path (lines 1948-1968, catchCINotFound): When item not found, showGroupAsSender is derived from asGroup_ flag (or defaults based on m_), which maps to sentAsGroup in the DeliveryTaskContext.
Issue 4: DeliveryTaskContext type
File: src/Simplex/Chat/Delivery.hs
4a. Add DeliveryTaskContext type
data DeliveryTaskContext = DeliveryTaskContext
{ jobScope :: DeliveryJobScope,
sentAsGroup :: ShowGroupAsSender
}
deriving (Show)
Uses existing type ShowGroupAsSender = Bool from Messages.hs.
4b. Modify existing helpers
Rename infoToDeliveryScope → infoToDeliveryContext, inline the scope logic, add ShowGroupAsSender parameter:
infoToDeliveryContext :: GroupInfo -> Maybe GroupChatScopeInfo -> ShowGroupAsSender -> DeliveryTaskContext
infoToDeliveryContext GroupInfo {membership} scopeInfo sentAsGroup = DeliveryTaskContext {jobScope, sentAsGroup}
where
jobScope = case scopeInfo of
Nothing -> DJSGroup {jobSpec = DJDeliveryJob {includePending = False}}
Just GCSIMemberSupport {groupMember_} ->
let supportGMId = groupMemberId' $ fromMaybe membership groupMember_
in DJSMemberSupport {supportGMId}
Remove infoToDeliveryScope entirely.
Rename memberEventDeliveryScope → memberEventDeliveryContext, change return type:
memberEventDeliveryContext :: GroupMember -> Maybe DeliveryTaskContext
memberEventDeliveryContext m@GroupMember {memberRole, memberStatus}
| memberStatus == GSMemPendingApproval = Nothing
| memberStatus == GSMemPendingReview = Just $ DeliveryTaskContext {jobScope = DJSMemberSupport {supportGMId = groupMemberId' m}, sentAsGroup = False}
| memberRole >= GRModerator = Just $ DeliveryTaskContext {jobScope = DJSGroup {jobSpec = DJDeliveryJob {includePending = True}}, sentAsGroup = False}
| otherwise = Just $ DeliveryTaskContext {jobScope = DJSGroup {jobSpec = DJDeliveryJob {includePending = False}}, sentAsGroup = False}
4c. Update NewMessageDeliveryTask
data NewMessageDeliveryTask = NewMessageDeliveryTask
{ messageId :: MessageId,
taskContext :: DeliveryTaskContext
}
deriving (Show)
4d. MessageDeliveryTask — no change
MessageDeliveryTask stays as-is. It's constructed from DB rows in getMsgDeliveryTask_ and consumed by relay forwarding code — those consumers need jobScope and fwdSender directly, not DeliveryTaskContext. DeliveryTaskContext is only for the path from processing functions → NewMessageDeliveryTask creation.
4e. Update Store/Delivery.hs
createMsgDeliveryTask (line 71-87): Extract jobScope and sentAsGroup from taskContext instead of separate jobScope/showGroupAsSender fields.
getMsgDeliveryTask_ — no change needed (MessageDeliveryTask unchanged).
4f. Consumers of MessageDeliveryTask — no change needed
Subscriber.hs lines ~3325-3333 and Messages/Batch.hs lines ~77-80 already pattern match on FwdSender and use jobScope from MessageDeliveryTask. Since MessageDeliveryTask is unchanged, no updates needed.
4g. Return type changes in processing functions
All functions currently returning (Maybe DeliveryJobScope, ShowGroupAsSender) change to Maybe DeliveryTaskContext:
groupMessageFileDescription→CM (Maybe DeliveryTaskContext)groupMessageUpdate→CM (Maybe DeliveryTaskContext)groupMessageDelete→CM (Maybe DeliveryTaskContext)xFileCancelGroup→CM (Maybe DeliveryTaskContext)groupMsgReaction→CM (Maybe DeliveryTaskContext)
Events that return (Nothing, False) or (Just scope, False) are updated:
(Nothing, False)→Nothing(Just scope, False)→Just $ DeliveryTaskContext scope False(or usememberEventDeliveryContext)(Just scope, showGroupAsSender)→Just $ DeliveryTaskContext scope showGroupAsSender(or useinfoToDeliveryContext)
Issue 5: Fix testChannelReactionAttribution
File: tests/ChatTests/Groups.hs lines 9057-9084
Problem: Comment says "reaction is forwarded as channel (owner is anonymous)" and expects #team>. Owner should react as member — reactions are always sentAsGroup = False.
Fix: Change comment and expectations:
-- owner reacts to own member message - reaction is forwarded as member
alice ##> "+1 #team hello"
alice <## "added 👍"
bob <# "#team alice> > alice hello"
bob <## " + 👍"
concurrentlyN_
[ do cath <# "#team alice> > alice hello"
cath <## " + 👍",
do dan <# "#team alice> > alice hello"
dan <## " + 👍",
do eve <# "#team alice> > alice hello"
eve <## " + 👍"
]
Issue 6: Fix testChannelUpdateFallbackSendAsGroup comment
File: tests/ChatTests/Groups.hs line 9127
Problem: Comment says "bob's internally deleted item is still in DB, update finds it with correct member direction". This is wrong — the item was internally deleted, then XMsgUpdate re-creates it via the catchCINotFound fallback.
Fix: Change comment to:
-- bob's internally deleted item is re-created as from member (sendAsGroup=False)
Other: sendAsGroup parameter ordering
Problem: sendAsGroup/ShowGroupAsSender should come right after direction/scope, not at the end.
7a. APIForwardChatItems constructor
File: src/Simplex/Chat/Library/Commands.hs (ChatCommand type definition + parser)
Current: APIForwardChatItems toChat fromChat itemIds itemTTL sendAsGroup
New: APIForwardChatItems toChat sendAsGroup fromChat itemIds itemTTL
Affects:
- Constructor definition in
src/Simplex/Chat/Controller.hsline 341 - Parser at line 4639
- Call sites at lines 930, 2192, 2198, 2204
7b. createNewSndChatItem
File: src/Simplex/Chat/Store/Messages.hs line 528
Current: createNewSndChatItem db user chatDirection msg ciContent quotedItem itemForwarded timed live hasLink showGroupAsSender createdAt
New: createNewSndChatItem db user chatDirection showGroupAsSender msg ciContent quotedItem itemForwarded timed live hasLink createdAt
Move showGroupAsSender right after chatDirection (direction context).
Affects call site in Internal.hs line 2276.
7c. saveSndChatItems
File: src/Simplex/Chat/Library/Internal.hs line 2256-2265
Current param order: user -> cd -> itemsData -> itemTimed -> live -> showGroupAsSender
New: user -> cd -> showGroupAsSender -> itemsData -> itemTimed -> live
Move showGroupAsSender right after cd (direction context).
Affects call sites: Internal.hs line 2242, Commands.hs lines 2561, 2608 (and the saveSndChatItem' wrapper at line 2240).
7d. prepareGroupMsg
File: src/Simplex/Chat/Library/Internal.hs line 203
Current: prepareGroupMsg db user gInfo msgScope mc mentions quotedItemId_ itemForwarded fInv_ timed_ live showGroupAsSender
New: prepareGroupMsg db user gInfo msgScope showGroupAsSender mc mentions quotedItemId_ itemForwarded fInv_ timed_ live
Move showGroupAsSender right after msgScope (scope context).
Affects call sites: Internal.hs line 1249, Commands.hs line 4094.
Forwarded handler (xGrpMsgForward) changes
File: src/Simplex/Chat/Library/Subscriber.hs lines 3136-3173
Add withAuthor helper to replace ad-hoc | Just author <- author_ guards:
where
withAuthor :: CMEventTag e -> (GroupMember -> CM ()) -> CM ()
withAuthor tag action = case author_ of
Just author -> action author
Nothing -> messageError $ "x.grp.msg.forward: event " <> tshow tag <> " requires author"
Update forwarded event handling:
XMsgFileDescr→ passauthor_(Maybe GroupMember) directlyXMsgUpdate→ passauthor_directly, void resultXMsgDel→ passauthor_directly, void resultXMsgReact→ usewithAuthor(required member)XFileCancel→ passauthor_directly- Other events with
| Just author <- author_→ usewithAuthor
Files Modified
| File | Changes |
|---|---|
src/Simplex/Chat/Delivery.hs |
Add DeliveryTaskContext, update NewMessageDeliveryTask only |
src/Simplex/Chat/Store/Delivery.hs |
Update createMsgDeliveryTask to extract from taskContext |
src/Simplex/Chat/Library/Subscriber.hs |
Eliminate isChannelOwner/memberForChannel/memberIdForChannel; change function signatures to return Maybe DeliveryTaskContext; add withAuthor; simplify validSender; groupMsgReaction required member; fix lookup |
src/Simplex/Chat/Controller.hs |
Reorder sendAsGroup in APIForwardChatItems constructor |
src/Simplex/Chat/Library/Commands.hs |
Reorder sendAsGroup in APIForwardChatItems parser + call sites |
src/Simplex/Chat/Store/Messages.hs |
Reorder showGroupAsSender in createNewSndChatItem |
src/Simplex/Chat/Library/Internal.hs |
Reorder showGroupAsSender in saveSndChatItems, prepareGroupMsg |
src/Simplex/Chat/Messages/Batch.hs |
No change needed (MessageDeliveryTask unchanged) |
tests/ChatTests/Groups.hs |
Fix reaction test expectations + update fallback comment |
Verification
cabal build --ghc-options=-O0— must compile clean- Run channel test suite:
cabal test simplex-chat-test --test-option='-m "channels"' --ghc-options=-O0 - Adversarial self-review loop until 2 consecutive clean passes
- Verify no
isChannelOwnerreferences remain in Subscriber.hs direct handler - Verify
groupMsgReactionsignature has requiredGroupMember(no Maybe) - Verify no dual-lookup with
catchErroringroupMessageUpdate