core, ios: fix notifications for forwarded and channel messages (#7706)

* plan: add notification flag for forwarded messages

* core, ui: fix notifications for forwarded and relay messages

* improvements

* only send duplicate message event in NSE

* query plans

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
This commit is contained in:
Evgeny
2026-10-15 08:41:57 +01:00
committed by GitHub
co-authored by Evgeny @ SimpleX Chat
parent f5b13f608f
commit e8c98e8231
22 changed files with 674 additions and 60 deletions
+3
View File
@@ -89,6 +89,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult {
case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?)
case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest)
case newChatItems(user: UserRef, chatItems: [AChatItem])
case duplicateGroupMessage(user: UserRef, member: GroupMember)
case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer)
case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer)
case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer)
@@ -101,6 +102,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult {
case .contactConnected: "contactConnected"
case .receivedContactRequest: "receivedContactRequest"
case .newChatItems: "newChatItems"
case .duplicateGroupMessage: "duplicateGroupMessage"
case .rcvFileSndCancelled: "rcvFileSndCancelled"
case .sndFileComplete: "sndFileComplete"
case .sndFileRcvCancelled: "sndFileRcvCancelled"
@@ -117,6 +119,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult {
case let .newChatItems(u, chatItems):
let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n")
return withUser(u, itemsString)
case let .duplicateGroupMessage(u, member): return withUser(u, String(describing: member))
case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem))
case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem))
case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem))
+16 -4
View File
@@ -73,6 +73,16 @@ public enum NSENotificationData {
default: nil
}
}
func replaces(_ bestAttempt: NSENotificationData) -> Bool {
switch (self, bestAttempt) {
case (.noNtf, .connectionEvent): true
case (.noNtf, _): false
case (.callInvitation, _): true
case (_, .callInvitation): false
default: true
}
}
}
// Once the last thread in the process completes processing chat controller is suspended, and the database is closed, to avoid
@@ -573,9 +583,9 @@ class NotificationService: UNNotificationServiceExtension {
// A better logic could be to check whether we have a call in the best attempt while processing .msgInfo marker above.
// If the best attempt is a call, and its marker is received, and the call is recent (e.g., the last 30 seconds), it would deliver at once,
// instead of requesting further messages.
if ntfEntity.msgBestAttemptNtf.callInvitation == nil || ntf.callInvitation != nil {
if let bestAttempt = notificationEntities[id]?.msgBestAttemptNtf, ntf.replaces(bestAttempt) {
notificationEntities[id]?.msgBestAttemptNtf = ntf
} // otherwise keep call as best attempt
}
return false
} else {
// We should not get to this branch, as notifications are not delivered for muted users.
@@ -1022,8 +1032,8 @@ func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)?
cItem = autoReceiveFile(file) ?? cItem
}
let ntf: NSENotificationData = (cInfo.ntfsEnabled(chatItem: cItem) && cItem.showNotification) ? .messageReceived(user, cInfo, cItem) : .noNtf
let chatIdOrMemberId = if case let .groupRcv(groupMember) = chatItem.chatItem.chatDir {
groupMember.id
let chatIdOrMemberId = if case let .group(groupInfo, _) = cInfo, let memberId = cItem.meta.forwardedByMember ?? cItem.chatDir.groupMemberId {
groupMemberEntityId(groupInfo.groupId, memberId)
} else {
chatItem.chatInfo.id
}
@@ -1031,6 +1041,8 @@ func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)?
} else {
return nil
}
case let .duplicateGroupMessage(_, member):
return (member.id, .noNtf)
case let .rcvFileSndCancelled(_, aChatItem, _):
cleanupFile(aChatItem)
return nil
+10 -1
View File
@@ -3255,7 +3255,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
public var relayLink: String?
public var memberVerifiedCode: SecurityCode?
public var id: String { "#\(groupId) @\(groupMemberId)" }
public var id: String { groupMemberEntityId(groupId, groupMemberId) }
public var ready: Bool { get { activeConn?.connStatus == .ready } }
public var sndReady: Bool { get { ready || activeConn?.connStatus == .sndReady } }
public var sendMsgEnabled: Bool { get {
@@ -3444,6 +3444,10 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
)
}
public func groupMemberEntityId(_ groupId: Int64, _ groupMemberId: Int64) -> String {
"#\(groupId) @\(groupMemberId)"
}
public struct GroupSupportChat: Codable, Hashable {
public var chatTs: Date
public var unread: Int
@@ -4217,6 +4221,10 @@ public enum CIDirection: Decodable, Hashable {
}
}
public var groupMemberId: Int64? {
if case let .groupRcv(groupMember) = self { groupMember.groupMemberId } else { nil }
}
public func sameDirection(_ dir: CIDirection) -> Bool {
switch (self, dir) {
case let (.groupRcv(m1), .groupRcv(m2)): m1.groupMemberId == m2.groupMemberId
@@ -4242,6 +4250,7 @@ public struct CIMeta: Decodable, Hashable {
public var userMention: Bool
public var deletable: Bool
public var editable: Bool
public var forwardedByMember: Int64?
public var showGroupAsSender: Bool
public var msgVerified: MsgVerified?
+1
View File
@@ -203,6 +203,7 @@ undocumentedEvents =
"CEvtContactRequestRejected",
"CEvtContactSwitch",
"CEvtCustomChatEvent",
"CEvtDuplicateGroupMessage",
"CEvtGroupMemberRatchetSync",
"CEvtGroupMemberSwitch",
"CEvtServiceSubStatus",
@@ -0,0 +1,330 @@
# Notification flag of forwarded messages
Date: 2026-10-13.
A message forwarded by a chat relay or by a forwarding group host is sent with the SMP notification flag when its original event has the flag by the rule of `hasNotification`.
References are to `/code/simplex-chat-4`, branch `master`, commit `2f4a443dd`. Haskell paths without a directory are in `src/Simplex/Chat/`. `SMQ/` is `dist-newstyle/src/simplexmq-f058c38b0eecb6c0/src/Simplex/Messaging/`. `NSE` is `apps/ios/SimpleX NSE/NotificationService.swift`.
## 1. Present behaviour
Path of a forwarded message:
```
received Library/Subscriber.hs:1014 MSG of a member connection; the flag of the received SMP message is ignored
Library/Subscriber.hs:1069-1144 processEvent: a NewMessageDeliveryTask is returned for each event to forward
task Library/Subscriber.hs:1022 created when the user is the relay of a channel, or an admin or owner of a group (:3800), and the author is not blocked by an admin
Library/Subscriber.hs:1158-1176 createDeliveryTasks; createMsgDeliveryTask (Store/Delivery.hs:78) is called at :1165 and :1172 only
Store/Delivery.hs:137-170 the task is read with its message: msg_body is decoded to verifiedMsg, the original event
job Library/Subscriber.hs:4233-4240 DJDeliveryJob: accepted tasks of one scope (Store/Delivery.hs:178) are batched into one job (Messages/Batch.hs:84)
Library/Subscriber.hs:4246-4251 DJRelayRemoved: one job for one task
Library/Commands.hs:3256-3265 leaveChannelRelay: one job with the x.grp.leave of the relay
Store/Delivery.hs:285-328 the job is read as scope, sender ids, body and cursor; its events are stored only as the encoded body
sent Library/Subscriber.hs:4379-4423 channel: for each recipient, overflow profile batches, then the body; MsgFlags False (:4416)
Library/Subscriber.hs:4424-4487 channel support scope and regular group: deliver; MsgFlags False (:4483)
```
Types (`Delivery.hs`):
```
DeliveryTaskContext :48
jobScope :: DeliveryJobScope
sentAsGroup :: ShowGroupAsSender
DeliveryJobScope :54
DJSGroup {jobSpec :: DeliveryJobSpec}
DJSMemberSupport {supportGMId :: GroupMemberId}
DeliveryJobSpec :60
DJDeliveryJob {includePending :: Bool}
DJRelayRemoved
NewMessageDeliveryTask :122
messageId :: MessageId
taskContext :: DeliveryTaskContext
MessageDeliveryTask :128
taskId :: Int64
jobScope :: DeliveryJobScope
senderGMId :: GroupMemberId
fwdSender :: FwdSender
brokerTs :: UTCTime
verifiedMsg :: VerifiedMsg 'Json
MessageDeliveryJob :161
jobId :: Int64
jobScope :: DeliveryJobScope
senderGMIds :: [GroupMemberId]
body :: ByteString
cursorGMId_ :: Maybe GroupMemberId
```
Tables (`Store/SQLite/Migrations/M20250813_delivery_tasks.hs:54, :103`, `M20260529_delivery_job_senders.hs:17`; the same columns in Postgres):
```
both tables
group_id
worker_scope
job_scope_spec_tag
job_scope_include_pending
job_scope_support_gm_id
failed
created_at
updated_at
delivery_tasks
delivery_task_id
sender_group_member_id
message_id -- messages.chat_msg_event: the tag; messages.msg_body: the original event
message_from_channel
task_status
task_err_reason
delivery_jobs
delivery_job_id
sender_group_member_ids
body
cursor_group_member_id
job_status
job_err_reason
```
The flag, from the forwarder to the notification:
```
forwarder agent SMQ/Agent.hs:2186-2203, 2563 one flag for each MsgReq, stored with the sent message; a body shared by VRRef is sent with the flag of each request
SMQ/Agent.hs:2637-2644 the flag is a parameter of SEND
SMP server SMQ/Server.hs:2038-2039 a notification is stored for a flagged message to a queue with notifier credentials (:2131-2136), and for such a message only
SMQ/Server.hs:361-414 the stored notification is sent as NMSG to the subscriber of the notifier, the notification server
notification server SMQ/Notifications/Server.hs:545-553 NMSG: a push notification with the last notifications of the device token is sent to the push provider (:693-702)
recipient Library/Subscriber.hs:890 the notification subscription of a member connection is created unless the chat is muted, MFNone (Library/Commands.hs:1922-1929; SMQ/Agent.hs:3345-3361)
SMQ/Agent.hs:2093-2144, 3741 the agent gets the message of the notified queue and passes MSG with the flag; forwarded content is opaque to the agent
Library/Subscriber.hs:1014 the core ignores the flag; the item is created from the forwarded event (:4008-4064)
```
iOS notification extension, for a forwarded message:
```
entity key NSE:489-498 id of the notified connection: "#<groupId> @<groupMemberId>" of the relay or the host (apps/ios/SimpleXChat/ChatTypes.swift:3624-3632, :3258)
item key NSE:1025-1029 the member of the item, its author, for groupRcv; "#<groupId>" for channelRcv
forwarded item NSE:173-187 the keys are different: the notification of the item is added to droppedNotifications, outside the entity of the notified connection
shown NSE:1196-1216 the default of the connection: MFAll: title "#<group> <relay>:", text "message received" (apps/ios/SimpleXChat/Notifications.swift:102); MFMentions: empty content
```
- With the flag alone the push notification is sent, and the message is received by the core of the NSE: the item is created. The shown notification is the default one. The NSE change of section 7 is required for a notification with the message text.
Regular groups, the same message sent to a member by its author and by the host:
```
author Library/Internal.hs:2847-2865 member with a ready connection: sent to the member; otherwise forwarded by the host (MSAForwarded) or stored as pending
host Library/Subscriber.hs:4447-4458 job recipients: members whose relation with the sender is MRIntroduced
both sent while the author is connected to the member and the relation at the host is MRIntroduced
the second copy is a duplicate: one item; the recipient sends x.grp.mem.con (Library/Internal.hs:2939-2945, 2958-2968), the host stops forwarding (Library/Subscriber.hs:3724-3729)
rule each copy of x.msg.new is flagged: two notifications for one item in that interval, one otherwise
```
## 2. Rule
```
forwarded message flag = hasNotification of the tag of its original event (Protocol.hs:1338)
the flag of the received SMP message is unused
SMP message notification = True when the flag of any forwarded message of its body is True, as for sent events (Library/Internal.hs:2248, 2748)
job one value for the body, computed when the job is created; the same for every recipient, scope and cursor page
channel pieces body (id 0) and extBody, profiles with the body (id 1): the job value
overflow profile batches (id 2 and above): False; their elements are x.grp.mem.new of the relay (Library/Subscriber.hs:4277)
```
Events (handlers at `Library/Subscriber.hs:1080-1132`; forwarded events are those of `isForwardedGroupMsg`, `Protocol.hs:528`):
```
event flag
x.msg.new True every content message, with or without a file
x.grp.del True forwarded by the relay-removed job (:1118)
x.msg.file.descr False
x.msg.update False
x.msg.del False
x.msg.react False
x.file.cancel False
x.info False
x.grp.relay.new False
x.grp.mem.new False
x.grp.mem.role False
x.grp.mem.restrict False
x.grp.mem.del False also the relay-removed job of a removed relay or host (:3746)
x.grp.leave False also the job of a relay that leaves (Library/Commands.hs:3263)
x.grp.info False
x.grp.prefs False
x.grp.roster False
x.file True handled without a delivery task (:1096)
x.grp.mem.fwd True handled without a delivery task (:1110)
x.grp.inv True handled without a delivery task (:1124)
x.call.inv True handled without a delivery task (:1132)
x.contact True handled without a delivery task (:1132)
```
## 3. Core changes
```
Protocol.hs
msgHasNotification new, next to hasNotification (:1338): VerifiedMsg e -> Bool
hasNotification of the tag of the event of the message (verifiedChatMsg, :1583)
Delivery.hs
MessageDeliveryJob (:161) notification :: Bool -- new, after body
Store/Delivery.hs
createMsgDeliveryJob (:251) new parameter notification :: Bool, before body; written to delivery_jobs.notification
MessageDeliveryJobRow (:283) BoolInt added after the body
getNextDeliveryJob (:309-328) notification selected after body; set in MessageDeliveryJob
Library/Subscriber.hs
processDeliveryTask (:4235) notification: True when msgHasNotification of verifiedMsg is True for any of acceptedTasks; computed next to senderGMIds, passed at :4237
(:4250) msgHasNotification verifiedMsg of the task is passed
processDeliveryJob (:4331) notification is bound from the job
recipientBodyPieces (:4417) each piece is returned with its flag: overflow batch False; extBody and body: notification
addPiece (:4410-4416) MsgFlags {notification = the flag of the piece}
deliver (:4483) MsgFlags {notification}
Library/Commands.hs
leaveChannelRelay (:3263) hasNotification XGrpLeave_ is passed
Library/Internal.hs
sendFwdMemberMessage (:2903) MsgFlags {notification = msgHasNotification verifiedMsg}
False for both callers: x.grp.roster (:1307), x.grp.mem.del (Library/Subscriber.hs:3798)
```
Sites with a fixed flag, unchanged:
```
Library/Internal.hs:1428 sendHistory: history for a new member, with the welcome message of a group; MsgFlags False (open question 1)
Library/Internal.hs:2459 sendGroupMemberMessages: events of the user to one member; notification = True
Library/Internal.hs:2910 sendPendingGroupMessages: pending messages of the user to one member; notification = True
tests/ChatTests/Groups.hs:5681, 10907, 13320, 13452, 13608, 13701 elements sent by tests through the agent; MsgFlags False
```
- Every other `MsgFlags` value of the repository is built for events of the user, from `hasNotification` of their tags: `Library/Internal.hs:2248, 2552, 2748`; `Library/Commands.hs:2672, 4131`; `Library/Subscriber.hs:356`.
- Forwarded content is sent as `>` elements only (`encodeFwdElement`, `Messages/Batch.hs:125`; used at `:98`, `Library/Subscriber.hs:4248`, `Library/Internal.hs:1423, 2902`). `XGrpMsgForward` events are only received (`Library/Subscriber.hs:1127`).
## 4. Storage
Migration `M20261012_delivery_job_notification`, SQLite and Postgres, registered in `Store/SQLite/Migrations.hs` (`:180`, `:358`), `Store/Postgres/Migrations.hs` (`:57`, `:112`) and `simplex-chat.cabal` (`:172`, `:351`). It is one `ADD COLUMN`; indexes are unchanged.
```
delivery_jobs
notification INTEGER NOT NULL DEFAULT 0 -- SMALLINT in Postgres
```
- Down migration: the column is dropped. `delivery_tasks` is unchanged: the event of a task is read with its message.
- Applied migrations must be a prefix of the list sorted by name (`SMQ/Agent/Store/Migrations.hs:23-34`; `Store/SQLite/Migrations.hs:363`). The name is ordered after `20261010_invite_member_via_group` and before the unreleased `20261013_chat_layout` and `20261013_msg_priority` of branch `ep/llm-ux` (open question 3).
- The schema dumps and `chat_query_plans.txt` are regenerated by the tests "verify and overwrite schema dump" (`tests/SchemaDump.hs:58`, `tests/PostgresSchemaDump.hs:29`) and "verify and overwrite query plans" (`tests/SchemaDump.hs:165`); they are never edited by hand.
## 5. Compatibility and cost
```
chat protocol unchanged; the flag is a parameter of SMP SEND (SMQ/Protocol.hs:598)
recipient every version; the flag is used by the SMP server only
earlier relay or host sends without the flag, as now; a channel member is notified by each relay of this release
job of an earlier release notification = 0: sent without the flag
downgrade the column is dropped
traffic count and size of SMP messages unchanged
notifications one for each flagged SMP message to a queue with notifier credentials: a device with a notification token and the chat set to MFAll or MFMentions
one job is one flagged SMP message for a recipient, for all its events
channel a post is sent by each relay to each member (Library/Internal.hs:2843-2845; Store/Delivery.hs:352)
with N members and R relays up to N × R notifications are sent for a post; one item is created (Library/Internal.hs:2958-2959)
shown on iOS: one notification for a post; a duplicate copy is shown with empty content (section 7)
profile overflow batches unflagged
```
## 6. Tests
`tests/ChatTests/Groups.hs`, helpers next to `relayConnIdToMember` (`:13140`):
```
agentSendBody cc connId body body sent to the connection by the agent of cc, MsgFlags False; a failure is a test failure
lastSndMsgFlags cc connId n msg_flags, "T" or "F", of the last n sent rows of type A_MSG ("M") of agent table messages for the connection, in sending order; withCCAgentTransaction (tests/ChatTests/Utils.hs:675)
lastJobNotification cc notification of the last row of delivery_jobs; withCCTransaction
```
- The agent row of a sent message is kept after delivery until its receipt (`SMQ/Agent/Store/AgentStore.hs:1318-1349`; `SMQ/Agent.hs:2866-2869`): recipients run `/set receipts all off` before the checked messages (as `:1561`).
New tests:
```
group message forwarding (:191)
testGroupMsgForwardNotification createGroup3, setupGroupForwarding (:5802); bob sends a message: alice to cath "T", job True; bob edits it: "F", job False
relay delivery, single relay (:283)
testChannels1RelayDeliverNotification createChannel1Relay (:9523); alice posts: bob to cath, dan and eve "T", job True; cath adds a reaction: bob to dan and eve "F", job False
deliver member profiles via relay (:295)
testChannelOverflowProfileNotification the scenario of testChannelLargeProfileFits (:9956) with an image of 10000 characters and a message of 7000 characters, so that the profile is sent in an overflow batch: bob to dan, the rows added by the message: "F" for the overflow profile batch, "T" for the message
```
- One sender and one message give one job.
Assertions added to present tests, on `delivery_jobs` only (the connections and their agent rows are deleted after the job, `Library/Subscriber.hs:4328`):
```
testGroupMsgForwardGroupDeletion (:6135) alice: job True, x.grp.del
testChannelDeleteGroupSigned (:11031) bob: job True, x.grp.del
testGroupMsgForwardAdminRemoval (:6096) alice: job False, x.grp.mem.del
testChannelRelayLeave (:11220) bob: job False, x.grp.leave
```
Present tests of delivery jobs, unchanged: "group message forwarding" (`:191-206`), "group scoped messages" (`:255-264`), "relay delivery" (`:282-292`), "deliver member profiles via relay" (`:295-301`), `testRelayBatchAllLarge` and `testRelayBatchSingletonOverflow` (`tests/MessageBatching.hs:164, 186`).
## 7. iOS notification of a forwarded message and of a duplicate copy
Event, `Controller.hs:1048`, next to `CEvtUnknownMemberAnnounced`:
```
CEvtDuplicateGroupMessage JSON type duplicateGroupMessage
user :: User
member :: GroupMember -- the member whose connection delivered the copy
```
Emission, `Library/Internal.hs`, before the present handling of the duplicate, only when the chat is started with `main=off` (the NSE and the share extension):
```
isMainApp :2976 subscriptionMode is SMSubscribe; startChatController sets SMOnlyCreate for main=off (Library/Commands.hs:235)
saveGroupRcvMsg :2925 direct copy; member: the member of the connection; every SEDuplicateGroupMessage
x.grp.mem.con to the forwarding member when the stored copy was forwarded, as now (:2940-2944)
saveGroupFwdRcvMsg :2959 channel; member: the forwarding relay; Nothing is returned, as now
:2960-2969 group; member: the forwarding host; x.grp.mem.con and the error, as now
```
Consumers:
```
terminal view View.hs:471: no output, as CEvtNtfMessage (:547)
API docs bots/src/API/Docs/Events.hs:205: in undocumentedEvents, as CEvtUnknownMemberCreated
apps, CLI, bots not emitted: started with main=on
NSE decoded: case duplicateGroupMessage (SimpleX NSE/NSEAPITypes.swift:91)
```
Swift types, `apps/ios/SimpleXChat/ChatTypes.swift`:
```
CIMeta.forwardedByMember :4253 Int64?, after editable, as in core CIMeta (Messages.hs:525); omitted in JSON for Nothing
groupMemberEntityId :3447 (groupId, groupMemberId) -> "#<groupId> @<groupMemberId>"; used by GroupMember.id (:3258) and the NSE
CIDirection.groupMemberId :4224 groupMemberId of the member of groupRcv; nil for other directions
```
NSE, `apps/ios/SimpleX NSE/NotificationService.swift`:
```
receivedMsgNtf (:1035-1039)
newChatItems key of a group item: groupMemberEntityId of the group and of the member whose connection delivered the item:
forwardedByMember, otherwise chatDir.groupMemberId; the id of the connection entity (ChatTypes.swift:3624-3632)
other items, and channelRcv without forwardedByMember: chatInfo.id, as now
duplicateGroupMessage key: member.id; notification: .noNtf
processReceivedNtf (:586-588): the best attempt of the entity is read from notificationEntities[id] and replaced when ntf.replaces(bestAttempt)
NSENotificationData.replaces (:77-85), new notification, best attempt:
.noNtf, .connectionEvent true: the default of the connection
.noNtf, other false
.callInvitation, any true
other, .callInvitation false
other, other true
```
- `.noNtf` is used: `.connectionEvent` is created only as the default (`defaultBestAttemptNtf`, `:1208-1229`). A received item, a contact event and a call are never replaced by `.noNtf`.
- The best attempt is read from `notificationEntities[id]`: `processDroppedNotifications` (`:514-529`) passes the entity copied before its notifications are processed.
Results:
```
forwarded post, MFAll the text of the post (createMessageReceivedNtf, apps/ios/SimpleXChat/Notifications.swift:66)
forwarded post, MFMentions the text for a mention or a reply; empty content otherwise
duplicate copy empty content, unless an item of the same push notification is shown
mention, then other item the mention is shown; before this patch, .noNtf of the other item replaced it
```
Tests, `tests/ChatTests/Groups.hs`; helpers next to `lastJobNotification`:
```
duplicateMessageEventsCfg testCfg with an eventHook (Controller.hs:257) that records the member names of CEvtDuplicateGroupMessage
setMainAppOff cc subscriptionMode of cc set to SMOnlyCreate, as by /_start main=off; called after the group is created
```
Copies are sent through the agent of the sender by `agentSendBody`, as in `testGroupMsgForwardOwnMembershipRejected` (`:5666`), so that the order of copies is fixed:
```
group message forwarding (:191)
testGroupMsgForwardDuplicateEvent createGroup3; bob's message is received by cath directly; alice's agent sends a forwarded copy
(Library/Internal.hs:2960-2969): cath "duplicate group message, group id: 1"; no event
setMainAppOff cath; alice's agent sends the copy again: the same line; event member alice
testGroupMsgDirectDuplicateEvent alice's agent sends a forwarded copy; setMainAppOff cath; bob's agent sends the direct copy
(Library/Internal.hs:2939-2946): cath "duplicate group message, group id: 1"; event member bob
relay delivery, single relay (:283)
testChannelDuplicateEvent createChannel1Relay; alice posts; setMainAppOff cath; bob's agent sends a second forwarded copy
(Library/Internal.hs:2959): event member bob; cath without output
```
- The NSE and the Swift types are not compiled on this machine. Manual checks on iOS: a post in a channel with 2 relays, with MFAll and with MFMentions; a forwarded message in a group; a mention followed by an ordinary message in one push notification.
## Interfaces
```
core functions msgHasNotification :: VerifiedMsg e -> Bool
createMsgDeliveryJob, new parameter notification
isMainApp :: CM Bool
core types MessageDeliveryJob.notification
core events CEvtDuplicateGroupMessage, JSON type duplicateGroupMessage
storage delivery_jobs.notification
migration M20261012_delivery_job_notification
iOS CIMeta.forwardedByMember
groupMemberEntityId
CIDirection.groupMemberId
NSEChatEvent.duplicateGroupMessage
NSENotificationData.replaces
test helpers agentSendBody
lastSndMsgFlags
lastJobNotification
duplicateMessageEventsCfg
setMainAppOff
```
- `eventHasNotification :: ChatMsgEvent e -> Bool` of `2026-10-13-message-priority.md` is accepted by one change: `msgHasNotification` is computed as `eventHasNotification` of the event of the message. Its three uses, the job column and `hasNotification XGrpLeave_` in `leaveChannelRelay` are unchanged.
- With that change a forwarded `x.msg.update` that raises priority is flagged. The line of the priority plan, section 4, "delivery jobs of relays and forwarding hosts, MsgFlags False for every message" is replaced by this plan.
## Open questions
1. History and the welcome message (`sendHistory`), flagged until commit `93831a0f2` (2026-07-17) and unflagged since: apply the rule? Recommended: unchanged, sent without the flag.
2. The job flag as a column, or computed by the job worker from the decoded body, without a migration? Recommended: the column, computed where the events of the tasks are read.
3. Release order with `ep/llm-ux`. Recommended: this correction first, with date `20261012` in the migration name; otherwise a date after the last released migration.
+2
View File
@@ -170,6 +170,7 @@ library
Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json
Simplex.Chat.Store.Postgres.Migrations.M20260925_badge_bindings
Simplex.Chat.Store.Postgres.Migrations.M20261010_invite_member_via_group
Simplex.Chat.Store.Postgres.Migrations.M20261012_delivery_job_notification
else
exposed-modules:
Simplex.Chat.Archive
@@ -349,6 +350,7 @@ library
Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json
Simplex.Chat.Store.SQLite.Migrations.M20260925_badge_bindings
Simplex.Chat.Store.SQLite.Migrations.M20261010_invite_member_via_group
Simplex.Chat.Store.SQLite.Migrations.M20261012_delivery_job_notification
other-modules:
Paths_simplex_chat
hs-source-dirs:
+1
View File
@@ -1046,6 +1046,7 @@ data ChatEvent
| CEvtUnknownMemberCreated {user :: User, groupInfo :: GroupInfo, forwardedByMember :: GroupMember, member :: GroupMember}
| CEvtUnknownMemberBlocked {user :: User, groupInfo :: GroupInfo, blockedByMember :: GroupMember, member :: GroupMember}
| CEvtUnknownMemberAnnounced {user :: User, groupInfo :: GroupInfo, announcingMember :: GroupMember, unknownMember :: GroupMember, announcedMember :: GroupMember}
| CEvtDuplicateGroupMessage {user :: User, member :: GroupMember}
| CEvtGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember, msgSigned :: Maybe MsgSigStatus}
| CEvtGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember, msgSigned :: Maybe MsgSigStatus} -- there is the same command response
| CEvtAcceptingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember}
+1
View File
@@ -163,6 +163,7 @@ data MessageDeliveryJob = MessageDeliveryJob
jobScope :: DeliveryJobScope,
senderGMIds :: [GroupMemberId],
body :: ByteString,
notification :: Bool,
cursorGMId_ :: Maybe GroupMemberId
}
deriving (Show)
+1 -1
View File
@@ -3260,7 +3260,7 @@ processChatCommand cxt nm = \case
withFastStore' $ \db -> do
deleteGroupDeliveryTasks db gInfo
deleteGroupDeliveryJobs db gInfo
createMsgDeliveryJob db gInfo (DJSGroup {jobSpec = DJRelayRemoved}) [] body
createMsgDeliveryJob db gInfo (DJSGroup {jobSpec = DJRelayRemoved}) [] (hasNotification XGrpLeave_) body
lift . void $ getDeliveryJobWorker True (groupId, DWSGroup)
pure msg
leaveGroupSendMsg user g@(GIK gInfo _) = do
+25 -18
View File
@@ -2900,7 +2900,7 @@ sendFwdMemberMessage :: GroupMember -> GrpMsgForward -> VerifiedMsg 'Json -> CM
sendFwdMemberMessage member fwd verifiedMsg =
forM_ (readyMemberConn member) $ \(_, conn) -> do
let body = encodeBinaryBatch [encodeFwdElement fwd verifiedMsg]
void $ withAgent $ \a -> sendMessages a [(aConnId conn, PQEncOff, MsgFlags False, VRValue Nothing body)]
void $ withAgent $ \a -> sendMessages a [(aConnId conn, PQEncOff, MsgFlags {notification = msgHasNotification verifiedMsg}, VRValue Nothing body)]
-- TODO ensure order - pending messages interleave with user input messages
sendPendingGroupMessages :: User -> GroupInfo -> GroupMember -> Connection -> CM ()
@@ -2937,11 +2937,13 @@ saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta
msg <-
withStore (\db -> createNewMessageAndRcvMsgDelivery db (GroupId groupId) newMsg sharedMsgId_ rcvMsgDelivery $ Just amGroupMemId)
`catchAllErrors` \e -> case e of
ChatErrorStore (SEDuplicateGroupMessage _ _ _ (Just forwardedByGroupMemberId)) -> do
cxt <- chatStoreCxt
fm <- withStore $ \db -> getGroupMember db cxt user groupId forwardedByGroupMemberId
forM_ (memberConn fm) $ \fmConn ->
void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemId) groupId
ChatErrorStore (SEDuplicateGroupMessage _ _ _ forwardedByGroupMemberId_) -> do
unlessM isMainApp $ toView $ CEvtDuplicateGroupMessage user am'
forM_ forwardedByGroupMemberId_ $ \forwardedByGroupMemberId -> do
cxt <- chatStoreCxt
fm <- withStore $ \db -> getGroupMember db cxt user groupId forwardedByGroupMemberId
forM_ (memberConn fm) $ \fmConn ->
void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemId) groupId
throwError e
_ -> throwError e
pure (am', conn', msg)
@@ -2955,20 +2957,25 @@ saveGroupFwdRcvMsg user gInfo@GroupInfo {groupId} forwardingMember refAuthorMemb
-- TODO [relays] TBC highlighting difference between deduplicated messages (useRelays branch)
withStore' (\db -> runExceptT $ createNewRcvMessage db (GroupId groupId) newMsg sharedMsgId_ refAuthorId fwdMemberId) >>= \case
Right msg -> pure $ Just msg
Left e@SEDuplicateGroupMessage {authorGroupMemberId, forwardedByGroupMemberId}
| useRelays' gInfo -> pure Nothing -- with chat relays, duplicates are expected
| otherwise -> case (authorGroupMemberId, forwardedByGroupMemberId) of
(Just authorGMId, Nothing) -> do
cxt <- chatStoreCxt
am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db cxt user groupId authorGMId
if maybe False (\ref -> sameMemberId (memberId' ref) am) refAuthorMember_
then forM_ (memberConn forwardingMember) $ \fmConn ->
void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemberId) groupId
else toView $ CEvtMessageError user "error" "saveGroupFwdRcvMsg: referenced author member id doesn't match message member id"
throwError $ ChatErrorStore e
_ -> throwError $ ChatErrorStore e
Left e@SEDuplicateGroupMessage {authorGroupMemberId, forwardedByGroupMemberId} -> do
unlessM isMainApp $ toView $ CEvtDuplicateGroupMessage user forwardingMember
if
| useRelays' gInfo -> pure Nothing -- with chat relays, duplicates are expected
| otherwise -> case (authorGroupMemberId, forwardedByGroupMemberId) of
(Just authorGMId, Nothing) -> do
cxt <- chatStoreCxt
am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db cxt user groupId authorGMId
if maybe False (\ref -> sameMemberId (memberId' ref) am) refAuthorMember_
then forM_ (memberConn forwardingMember) $ \fmConn ->
void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemberId) groupId
else toView $ CEvtMessageError user "error" "saveGroupFwdRcvMsg: referenced author member id doesn't match message member id"
throwError $ ChatErrorStore e
_ -> throwError $ ChatErrorStore e
Left e -> throwError $ ChatErrorStore e
isMainApp :: CM Bool
isMainApp = (SMSubscribe ==) <$> chatReadVar subscriptionMode
saveSndChatItem :: ChatTypeI c => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> CM (ChatItem c 'MDSnd)
saveSndChatItem user cd msg content = saveSndChatItem' user cd msg content Nothing Nothing Nothing Nothing False
+9 -8
View File
@@ -4233,8 +4233,9 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do
withWorkItems a doWork (withStore' $ \db -> getNextDeliveryTasks db gInfo task) $ \nextTasks -> do
let (body_, acceptedTasks, largeTasks) = batchDeliveryTasks1 (vr cxt) maxForwardBatchLength nextTasks
senderGMIds = S.toList . S.fromList $ map (\MessageDeliveryTask {senderGMId} -> senderGMId) acceptedTasks
notification = any (\MessageDeliveryTask {verifiedMsg} -> msgHasNotification verifiedMsg) acceptedTasks
withStore' $ \db -> do
forM_ body_ $ \body -> createMsgDeliveryJob db gInfo jobScope senderGMIds body
forM_ body_ $ \body -> createMsgDeliveryJob db gInfo jobScope senderGMIds notification body
forM_ acceptedTasks $ \t -> updateDeliveryTaskStatus db (deliveryTaskId t) DTSProcessed
forM_ largeTasks $ \t -> setDeliveryTaskErrStatus db (deliveryTaskId t) "large"
when (isJust body_) . lift . void $ getDeliveryJobWorker True deliveryKey
@@ -4247,7 +4248,7 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do
fwd = GrpMsgForward {fwdSender, fwdBrokerTs}
body = encodeBinaryBatch [encodeFwdElement fwd verifiedMsg]
withStore' $ \db -> do
createMsgDeliveryJob db gInfo jobScope [senderGMId] body
createMsgDeliveryJob db gInfo jobScope [senderGMId] (msgHasNotification verifiedMsg) body
updateDeliveryTaskStatus db (deliveryTaskId task) DTSProcessed
lift . void $ getDeliveryJobWorker True deliveryKey
@@ -4328,7 +4329,7 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do
deleteGroupConnections user gInfo True
withStore' $ \db -> updateDeliveryJobStatus db jobId DJSComplete
where
MessageDeliveryJob {jobId, jobScope, senderGMIds, body, cursorGMId_ = startingCursor} = job
MessageDeliveryJob {jobId, jobScope, senderGMIds, body, notification, cursorGMId_ = startingCursor} = job
singleSenderGMId_ = case senderGMIds of
[s] -> Just s
_ -> Nothing
@@ -4407,16 +4408,16 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do
addRecipient acc r = case readyMemberConn r of
Just (_, conn) -> snd $ foldl' (addPiece conn) (0 :: Int, acc) (recipientBodyPieces r)
Nothing -> acc
addPiece conn (k, (issued, reqs)) (bid, msgBody) =
addPiece conn (k, (issued, reqs)) (bid, ntf, msgBody) =
let vor
| IS.member bid issued = VRRef bid
| otherwise = VRValue (Just bid) msgBody
issued' = IS.insert bid issued
connId = if k == 0 then aConnId conn else B.empty
in (k + 1, (issued', (connId, PQEncOff, MsgFlags False, vor) : reqs))
in (k + 1, (issued', (connId, PQEncOff, MsgFlags {notification = ntf}, vor) : reqs))
recipientBodyPieces r =
[(i, b) | (i, (b, ss)) <- overflowWithIds, any missing ss]
<> [if any missing inBodySenders then (1, extBody) else (0, body)]
[(i, False, b) | (i, (b, ss)) <- overflowWithIds, any missing ss]
<> [if any missing inBodySenders then (1, notification, extBody) else (0, notification, body)]
where
missing s = case M.lookup (groupMemberId' s) senderVec of
Just vec -> getRelation (indexInGroup r) vec == MRNew
@@ -4480,7 +4481,7 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do
addReq conn (memIdx_, reqs) =
(subtract 1 <$> memIdx_, req : reqs)
where
req = (aConnId conn, PQEncOff, MsgFlags False, vrValue_)
req = (aConnId conn, PQEncOff, MsgFlags {notification}, vrValue_)
vrValue_ = case memIdx_ of
Nothing -> VRValue Nothing msgBody -- sending to one member, do not reference body
Just 1 -> VRValue (Just 1) msgBody
+5
View File
@@ -1346,6 +1346,11 @@ hasNotification = \case
XCallInv_ -> True
_ -> False
msgHasNotification :: VerifiedMsg e -> Bool
msgHasNotification verifiedMsg = hasNotification $ toCMEventTag chatMsgEvent
where
ChatMessage {chatMsgEvent} = verifiedChatMsg verifiedMsg
hasDeliveryReceipt :: CMEventTag e -> Bool
hasDeliveryReceipt = \case
XMsgNew_ -> True
+9 -9
View File
@@ -248,8 +248,8 @@ deleteDoneDeliveryTasks db createdAtCutoff = do
|]
(createdAtCutoff, DTSProcessed, DTSError)
createMsgDeliveryJob :: DB.Connection -> GroupInfo -> DeliveryJobScope -> [GroupMemberId] -> ByteString -> IO ()
createMsgDeliveryJob db gInfo jobScope senderGMIds body = do
createMsgDeliveryJob :: DB.Connection -> GroupInfo -> DeliveryJobScope -> [GroupMemberId] -> Bool -> ByteString -> IO ()
createMsgDeliveryJob db gInfo jobScope senderGMIds notification body = do
currentTs <- getCurrentTime
DB.execute
db
@@ -257,10 +257,10 @@ createMsgDeliveryJob db gInfo jobScope senderGMIds body = do
INSERT INTO delivery_jobs (
group_id,
worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id,
sender_group_member_ids, body, job_status, created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?)
sender_group_member_ids, body, notification, job_status, created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?)
|]
((Only groupId) :. jobScopeRow_ jobScope :. (senderColumn, Binary body, DJSPending, currentTs, currentTs))
((Only groupId) :. jobScopeRow_ jobScope :. (senderColumn, Binary body, BI notification, DJSPending, currentTs, currentTs))
where
GroupInfo {groupId} = gInfo
-- NULL ↔ []; non-empty list ↔ comma-separated decimal Int64s.
@@ -280,7 +280,7 @@ getPendingDeliveryJobScopes db =
|]
(Only DJSPending)
type MessageDeliveryJobRow = (Only Int64) :. DeliveryJobScopeRow :. (Maybe Text, Binary ByteString, Maybe GroupMemberId)
type MessageDeliveryJobRow = (Only Int64) :. DeliveryJobScopeRow :. (Maybe Text, Binary ByteString, BoolInt, Maybe GroupMemberId)
getNextDeliveryJob :: DB.Connection -> DeliveryWorkerKey -> IO (Either StoreError (Maybe MessageDeliveryJob))
getNextDeliveryJob db deliveryKey = do
@@ -310,14 +310,14 @@ getNextDeliveryJob db deliveryKey = do
SELECT
delivery_job_id,
worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id,
sender_group_member_ids, body, cursor_group_member_id
sender_group_member_ids, body, notification, cursor_group_member_id
FROM delivery_jobs
WHERE delivery_job_id = ?
|]
(Only jobId)
where
toDeliveryJob :: MessageDeliveryJobRow -> Either StoreError MessageDeliveryJob
toDeliveryJob ((Only jobId') :. jobScopeRow :. (senderGMIdsText_, Binary body, cursorGMId_)) = do
toDeliveryJob ((Only jobId') :. jobScopeRow :. (senderGMIdsText_, Binary body, BI notification, cursorGMId_)) = do
jobScope <- maybe (Left $ SEInvalidDeliveryJob jobId') Right $ toJobScope_ jobScopeRow
-- NULL or empty string means []; otherwise the value must parse
-- as a comma-separated decimal Int64 list. An unparseable
@@ -325,7 +325,7 @@ getNextDeliveryJob db deliveryKey = do
senderGMIds <- case senderGMIdsText_ of
Nothing -> Right []
Just t -> maybe (Left $ SEInvalidDeliveryJob jobId') Right $ parseSenderGMIds t
Right $ MessageDeliveryJob {jobId = jobId', jobScope, senderGMIds, body, cursorGMId_}
Right $ MessageDeliveryJob {jobId = jobId', jobScope, senderGMIds, body, notification, cursorGMId_}
parseSenderGMIds :: Text -> Maybe [GroupMemberId]
parseSenderGMIds t
| T.null t = Just []
@@ -55,6 +55,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260918_badge_issue_errors
import Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json
import Simplex.Chat.Store.Postgres.Migrations.M20260925_badge_bindings
import Simplex.Chat.Store.Postgres.Migrations.M20261010_invite_member_via_group
import Simplex.Chat.Store.Postgres.Migrations.M20261012_delivery_job_notification
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Text, Maybe Text)]
@@ -109,7 +110,8 @@ schemaMigrations =
("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors),
("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json),
("20260925_badge_bindings", m20260925_badge_bindings, Just down_m20260925_badge_bindings),
("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group)
("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group),
("20261012_delivery_job_notification", m20261012_delivery_job_notification, Just down_m20261012_delivery_job_notification)
]
-- | The list of migrations in ascending order by date
@@ -0,0 +1,19 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.Postgres.Migrations.M20261012_delivery_job_notification where
import Data.Text (Text)
import Text.RawString.QQ (r)
m20261012_delivery_job_notification :: Text
m20261012_delivery_job_notification =
[r|
ALTER TABLE delivery_jobs ADD COLUMN notification SMALLINT NOT NULL DEFAULT 0;
|]
down_m20261012_delivery_job_notification :: Text
down_m20261012_delivery_job_notification =
[r|
ALTER TABLE delivery_jobs DROP COLUMN notification;
|]
+3 -1
View File
@@ -178,6 +178,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260918_badge_issue_errors
import Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json
import Simplex.Chat.Store.SQLite.Migrations.M20260925_badge_bindings
import Simplex.Chat.Store.SQLite.Migrations.M20261010_invite_member_via_group
import Simplex.Chat.Store.SQLite.Migrations.M20261012_delivery_job_notification
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@@ -355,7 +356,8 @@ schemaMigrations =
("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors),
("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json),
("20260925_badge_bindings", m20260925_badge_bindings, Just down_m20260925_badge_bindings),
("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group)
("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group),
("20261012_delivery_job_notification", m20261012_delivery_job_notification, Just down_m20261012_delivery_job_notification)
]
-- | The list of migrations in ascending order by date
@@ -0,0 +1,18 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.SQLite.Migrations.M20261012_delivery_job_notification where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20261012_delivery_job_notification :: Query
m20261012_delivery_job_notification =
[sql|
ALTER TABLE delivery_jobs ADD COLUMN notification INTEGER NOT NULL DEFAULT 0;
|]
down_m20261012_delivery_job_notification :: Query
down_m20261012_delivery_job_notification =
[sql|
ALTER TABLE delivery_jobs DROP COLUMN notification;
|]
@@ -614,15 +614,6 @@ Query:
Plan:
SEARCH address_ratchet_keys USING INDEX idx_address_ratchet_keys (conn_id=? AND ratchet_key_id=?)
Query:
UPDATE rcv_messages
SET receive_attempts = receive_attempts + 1
WHERE conn_id = ? AND internal_id = ?
RETURNING receive_attempts
Plan:
SEARCH rcv_messages USING COVERING INDEX idx_rcv_messages_conn_id_internal_id (conn_id=? AND internal_id=?)
Query:
DELETE FROM address_ratchet_keys
WHERE conn_id = ?
@@ -1431,6 +1422,10 @@ Query: SELECT last_insert_rowid()
Plan:
SCAN CONSTANT ROW
Query: SELECT msg_flags FROM messages WHERE conn_id = ? AND internal_snd_id IS NOT NULL AND msg_type = ? ORDER BY internal_id DESC LIMIT ?
Plan:
SEARCH messages USING PRIMARY KEY (conn_id=?)
Query: SELECT ratchet_state FROM ratchets WHERE conn_id = ?
Plan:
SEARCH ratchets USING PRIMARY KEY (conn_id=?)
@@ -774,7 +774,7 @@ Query:
SELECT
delivery_job_id,
worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id,
sender_group_member_ids, body, cursor_group_member_id
sender_group_member_ids, body, notification, cursor_group_member_id
FROM delivery_jobs
WHERE delivery_job_id = ?
@@ -5058,8 +5058,8 @@ Query:
INSERT INTO delivery_jobs (
group_id,
worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id,
sender_group_member_ids, body, job_status, created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?)
sender_group_member_ids, body, notification, job_status, created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?)
Plan:
@@ -7838,6 +7838,10 @@ Query: SELECT note_folder_id FROM note_folders WHERE user_id = ? LIMIT 1
Plan:
SEARCH note_folders USING COVERING INDEX note_folders_user_id (user_id=?)
Query: SELECT notification FROM delivery_jobs ORDER BY delivery_job_id DESC LIMIT 1
Plan:
SCAN delivery_jobs
Query: SELECT purchase_key FROM badge_code_redemptions
Plan:
SCAN badge_code_redemptions
@@ -806,7 +806,8 @@ CREATE TABLE delivery_jobs(
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
,
sender_group_member_ids TEXT
sender_group_member_ids TEXT,
notification INTEGER NOT NULL DEFAULT 0
) STRICT;
CREATE TABLE group_member_status_predicates(
member_status TEXT NOT NULL PRIMARY KEY,
+1
View File
@@ -469,6 +469,7 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView}
CEvtUnknownMemberCreated u g fwdM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember fwdM <> " forwarded a message from an unknown member, creating unknown member record " <> ttyMember um]
CEvtUnknownMemberBlocked u g byM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember byM <> " blocked an unknown member, creating unknown member record " <> ttyMember um]
CEvtUnknownMemberAnnounced u g _ um m -> ttyUser u [ttyGroup' g <> ": unknown member " <> ttyMember um <> " updated to " <> ttyMember m]
CEvtDuplicateGroupMessage {} -> []
CEvtRcvFileDescrReady _ _ _ _ -> []
CEvtRcvFileAccepted u ci -> ttyUser u $ savingFile' ci
CEvtRcvFileAcceptedSndCancelled u ft -> ttyUser u $ viewRcvFileSndCancelled ft
+204 -4
View File
@@ -18,7 +18,7 @@ import ChatTests.Profiles (addTestBadge, futureDate, issueTestBadge, testBadgeKe
import ChatTests.Utils
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (concurrently_)
import Control.Concurrent.STM (atomically)
import Control.Concurrent.STM (TVar, atomically, modifyTVar', newTVarIO, readTVarIO, writeTVar)
import Control.Monad (forM_, void, when)
import Control.Monad.Except (runExceptT)
import Control.Monad.Reader (runReaderT)
@@ -32,14 +32,14 @@ import Data.List (intercalate, isInfixOf, isSuffixOf)
import qualified Data.Map.Strict as M
import qualified Data.Text as T
import Simplex.Chat.Badges (FileSizeLimits (..))
import Simplex.Chat.Controller (ChatController (ChatController, smpAgent), ChatConfig (..), ChatHooks (..), ChatLogLevel (..), defaultChatHooks, withAgent)
import Simplex.Chat.Controller (ChatController (ChatController, smpAgent, subscriptionMode), ChatConfig (..), ChatEvent (CEvtDuplicateGroupMessage), ChatHooks (..), ChatLogLevel (..), defaultChatHooks, withAgent)
import Simplex.Chat.Library.Internal (encodeConnInfo, getShortLinkConnReq, uniqueMsgMentions, updatedMentionNames)
import Simplex.Chat.Markdown (parseMaybeMarkdownList)
import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId)
import Simplex.Chat.Messages.Batch (encodeBinaryBatch, encodeFwdElement)
import Simplex.Chat.Messages.CIContent (publicGroupNoE2EText)
import Simplex.Chat.Options
import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew, XGrpRelayInv, XInfo, XMsgUpdate, XMsgNew, XMsgDel), FwdSender (FwdMember, FwdChannel), GrpMsgForward (GrpMsgForward), MsgContainer (..), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), mcSimple, msgContentText)
import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew, XGrpRelayInv, XInfo, XMsgUpdate, XMsgNew, XMsgDel), FwdSender (FwdMember, FwdChannel), GrpMsgForward (GrpMsgForward), MsgContainer (..), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), chatMsgToBody, mcSimple, msgContentText)
import Simplex.Chat.Types
import Simplex.Chat.Types.MemberRelations (MemberRelation (..), getRelation, setRelation)
import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..))
@@ -47,7 +47,7 @@ import Simplex.Messaging.Agent (joinConnection, prepareConnectionToJoin, sendMes
import Simplex.Messaging.Agent.Env.SQLite
import Simplex.Messaging.Agent.RetryInterval
import qualified Simplex.Messaging.Agent.Store.DB as DB
import Simplex.Messaging.Agent.Store.DB (Binary (..))
import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..))
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.BBS (bbsKeyGen)
import Simplex.Messaging.Client (pattern NRMInteractive)
@@ -194,7 +194,10 @@ chatGroupTests = do
it "forward batched messages" testGroupMsgForwardBatched
it "forward reports to moderators, don't forward to members (x.msg.new, MCReport)" testGroupMsgForwardReport
it "deduplicate forwarded messages" testGroupMsgForwardDeduplicate
it "report duplicate when forwarded copy is received after direct copy" testGroupMsgForwardDuplicateEvent
it "report duplicate when direct copy is received after forwarded copy" testGroupMsgDirectDuplicateEvent
it "forward message edit (x.msg.update)" testGroupMsgForwardEdit
it "set notification flag of forwarded messages by the original event" testGroupMsgForwardNotification
it "forward message reaction (x.msg.react)" testGroupMsgForwardReaction
it "forward message deletion (x.msg.del)" testGroupMsgForwardDeletion
it "forward file (x.msg.file.descr)" testGroupMsgForwardFile
@@ -282,6 +285,8 @@ chatGroupTests = do
describe "relay delivery" $ do
describe "single relay" $ do
it "should deliver messages to members" testChannels1RelayDeliver
it "should set notification flag of delivered messages" testChannels1RelayDeliverNotification
it "should report duplicate message delivered by relay" testChannelDuplicateEvent
describe "should deliver messages in a loop over members" $ do
it "number of recipients is multiple of bucket size (3/1)" (testChannels1RelayDeliverLoop 1)
it "number of recipients is NOT multiple of bucket size (3/2)" (testChannels1RelayDeliverLoop 2)
@@ -298,6 +303,7 @@ chatGroupTests = do
it "multi senders disseminate independently" testChannelMultiSendersIndependent
it "large profile fits in body" testChannelLargeProfileFits
it "multiple large profiles pack across batches in one multi-sender job" testChannelMultipleLargeProfiles
it "overflow profile batch is sent without notification flag" testChannelOverflowProfileNotification
it "profile update reuses existing announcement (no re-prepend)" testChannelProfileUpdateNoRePrepend
describe "channel operations" $ do
it "should update channel profile (signed)" testChannelUpdateProfileSigned
@@ -5912,6 +5918,53 @@ testGroupMsgForwardDeduplicate =
cath <#. "#team bob> hi there"
cath <# "#team hey team"
testGroupMsgForwardDuplicateEvent :: HasCallStack => TestParams -> IO ()
testGroupMsgForwardDuplicateEvent ps = do
(dups, cfg) <- duplicateMessageEventsCfg
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChat ps "bob" bobProfile $ \bob ->
withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do
createGroup3 "team" alice bob cath
groupForwardingRelations alice "bob" "cath" `shouldEventuallyReturn` (MRConnected, MRConnected)
bob #> "#team hi there"
[alice, cath] *<# "#team bob> hi there"
sharedMsgId <- itemSharedMsgId cath
bobMemId <- memberIdByName cath "bob"
connId <- relayConnIdToMember alice "cath"
ts <- getCurrentTime
let chatMsg = ChatMessage chatInitialVRange (Just sharedMsgId) (XMsgNew $ mcSimple $ MCText "hi there")
sendForwardedCopy = agentSendBody alice connId $ encodeBinaryBatch [encodeFwdElement (GrpMsgForward (FwdMember bobMemId "bob") ts) (VMUnsigned chatMsg)]
sendForwardedCopy
cath <##. "duplicate group message, group id: 1"
readTVarIO dups `shouldReturn` []
setMainAppOff cath
sendForwardedCopy
cath <##. "duplicate group message, group id: 1"
readTVarIO dups `shouldReturn` ["alice"]
testGroupMsgDirectDuplicateEvent :: HasCallStack => TestParams -> IO ()
testGroupMsgDirectDuplicateEvent ps = do
(dups, cfg) <- duplicateMessageEventsCfg
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChat ps "bob" bobProfile $ \bob ->
withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do
createGroup3 "team" alice bob cath
bobMemId <- memberIdByName cath "bob"
aliceConnId <- relayConnIdToMember alice "cath"
bobConnId <- relayConnIdToMember bob "cath"
ts <- getCurrentTime
let chatMsg = ChatMessage chatInitialVRange (Just $ SharedMsgId "duplicate_msg1") (XMsgNew $ mcSimple $ MCText "hi there")
agentSendBody alice aliceConnId $ encodeBinaryBatch [encodeFwdElement (GrpMsgForward (FwdMember bobMemId "bob") ts) (VMUnsigned chatMsg)]
cath <# "#team bob> hi there [>>]"
setMainAppOff cath
agentSendBody bob bobConnId $ chatMsgToBody chatMsg
cath <##. "duplicate group message, group id: 1"
readTVarIO dups `shouldReturn` ["bob"]
testGroupMsgForwardEdit :: HasCallStack => TestParams -> IO ()
testGroupMsgForwardEdit =
testChat3 aliceProfile bobProfile cathProfile $
@@ -5936,6 +5989,29 @@ testGroupMsgForwardEdit =
cath ##> "/tail #team 1"
cath <# "#team bob> hello there [>>]"
testGroupMsgForwardNotification :: HasCallStack => TestParams -> IO ()
testGroupMsgForwardNotification =
testChat3 aliceProfile bobProfile cathProfile $
\alice bob cath -> do
createGroup3 "team" alice bob cath
setupGroupForwarding alice bob cath
cath ##> "/set receipts all off"
cath <## "ok"
cathConnId <- relayConnIdToMember alice "cath"
bob #> "#team hi there"
alice <# "#team bob> hi there"
cath <# "#team bob> hi there [>>]"
lastSndMsgFlags alice cathConnId 1 `shouldReturn` ["T"]
lastJobNotification alice `shouldReturn` True
bob ##> "! #team hello there"
bob <# "#team [edited] hello there"
alice <# "#team bob> [edited] hello there"
cath <# "#team bob> [edited] hello there"
lastSndMsgFlags alice cathConnId 1 `shouldReturn` ["F"]
lastJobNotification alice `shouldReturn` False
testGroupMsgForwardReaction :: HasCallStack => TestParams -> IO ()
testGroupMsgForwardReaction =
testChat3 aliceProfile bobProfile cathProfile $
@@ -6118,6 +6194,7 @@ testGroupMsgForwardAdminRemoval =
alice <## "use /d #team to delete the group",
cath <## "#team: bob removed alice from the group (signed)"
]
lastJobNotification alice `shouldReturn` False
-- there is no forwarding admin anymore between bob and cath, so messages don't get delivered
-- (this is not a desired behavior, just a test demonstration/proof of current implementation)
@@ -6159,6 +6236,7 @@ testGroupMsgForwardGroupDeletion =
cath <## "#team: bob deleted the group (signed)"
cath <## "use /d #team to delete the local copy of the group"
]
lastJobNotification alice `shouldReturn` True
alice ##> "/groups"
alice <## "#team (group deleted, delete local copy: /d #team)"
@@ -9520,6 +9598,65 @@ testChannels1RelayDeliver ps =
cath <## "group ID: 1"
cath <## "subscribers: 4"
testChannels1RelayDeliverNotification :: HasCallStack => TestParams -> IO ()
testChannels1RelayDeliverNotification ps =
withNewTestChat ps "alice" aliceProfile $ \alice -> do
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do
withNewTestChat ps "cath" cathProfile $ \cath -> do
withNewTestChat ps "dan" danProfile $ \dan -> do
withNewTestChat ps "eve" eveProfile $ \eve -> do
createChannel1Relay "team" alice bob cath dan eve
forM_ [cath, dan, eve] $ \cc -> do
cc ##> "/set receipts all off"
cc <## "ok"
cathConnId <- relayConnIdToMember bob "cath"
danConnId <- relayConnIdToMember bob "dan"
eveConnId <- relayConnIdToMember bob "eve"
alice #> "#team hi"
bob <# "#team> hi"
[cath, dan, eve] *<# "#team> hi [>>]"
forM_ [cathConnId, danConnId, eveConnId] $ \connId ->
lastSndMsgFlags bob connId 1 `shouldReturn` ["T"]
lastJobNotification bob `shouldReturn` True
cath ##> "+1 #team hi"
cath <## "added 👍"
bob <# "#team cath> > hi"
bob <## " + 👍"
alice <# "#team cath> > hi"
alice <## " + 👍"
dan <## "#team: bob introduced cath (Catherine) in the channel"
dan <# "#team cath> > hi"
dan <## " + 👍"
eve <## "#team: bob introduced cath (Catherine) in the channel"
eve <# "#team cath> > hi"
eve <## " + 👍"
forM_ [danConnId, eveConnId] $ \connId ->
lastSndMsgFlags bob connId 1 `shouldReturn` ["F"]
lastJobNotification bob `shouldReturn` False
testChannelDuplicateEvent :: HasCallStack => TestParams -> IO ()
testChannelDuplicateEvent ps = do
(dups, cfg) <- duplicateMessageEventsCfg
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
withNewTestChatCfg ps cfg "cath" cathProfile $ \cath ->
withNewTestChat ps "dan" danProfile $ \dan ->
withNewTestChat ps "eve" eveProfile $ \eve -> do
createChannel1Relay "team" alice bob cath dan eve
alice #> "#team hi"
bob <# "#team> hi"
[cath, dan, eve] *<# "#team> hi [>>]"
sharedMsgId <- itemSharedMsgId cath
connId <- relayConnIdToMember bob "cath"
ts <- getCurrentTime
let chatMsg = ChatMessage chatInitialVRange (Just sharedMsgId) (XMsgNew $ mcSimple $ MCText "hi")
setMainAppOff cath
agentSendBody bob connId $ encodeBinaryBatch [encodeFwdElement (GrpMsgForward FwdChannel ts) (VMUnsigned chatMsg)]
readTVarIO dups `shouldEventuallyReturn` ["bob"]
createChannel1Relay :: String -> TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> IO ()
createChannel1Relay gName owner relay cath dan eve = do
(shortLink, fullLink) <- prepareChannel1Relay gName owner relay
@@ -9979,6 +10116,34 @@ testChannelLargeProfileFits ps =
memberIntroducedTo bob "cath" "dan"
testChannelOverflowProfileNotification :: HasCallStack => TestParams -> IO ()
testChannelOverflowProfileNotification ps =
withNewTestChat ps "alice" aliceProfile $ \alice -> do
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do
withNewTestChat ps "cath" cathProfile $ \cath -> do
withNewTestChat ps "dan" danProfile $ \dan -> do
(shortLink, fullLink) <- prepareChannel1Relay "team" alice bob
memberJoinChannel "team" [bob] [alice] shortLink fullLink cath
memberJoinChannel "team" [bob] [alice] shortLink fullLink dan
promoteChannelMember "team" alice bob cath [dan]
dan ##> "/set receipts all off"
dan <## "ok"
danConnId <- relayConnIdToMember bob "dan"
let bigImage = T.pack ("data:image/png;base64," <> replicate 10000 'A')
longText = replicate 7000 'x'
withCCTransaction bob $ \db ->
DB.execute db "UPDATE contact_profiles SET image = ? WHERE display_name = ?" (bigImage, "cath" :: T.Text)
sentBefore <- length <$> lastSndMsgFlags bob danConnId 100
cath #> ("#team " <> longText)
bob <# ("#team cath> " <> longText)
alice <# ("#team cath> " <> longText <> " [>>]")
dan <### [EndsWith "updated to cath"]
dan <## "#team: bob introduced cath (Catherine) in the channel"
dan <# ("#team cath> " <> longText <> " [>>]")
drop sentBefore <$> lastSndMsgFlags bob danConnId 100 `shouldReturn` ["F", "T"]
testChannelMultipleLargeProfiles :: HasCallStack => TestParams -> IO ()
testChannelMultipleLargeProfiles ps =
withNewTestChat ps "alice" aliceProfile $ \alice -> do
@@ -11053,6 +11218,7 @@ testChannelDeleteGroupSigned ps =
eve <## "#team: alice deleted the group (signed)"
eve <## "use /d #team to delete the local copy of the group"
]
lastJobNotification bob `shouldReturn` True
testChannelDeleteGroupCleanup :: HasCallStack => TestParams -> IO ()
testChannelDeleteGroupCleanup ps =
@@ -11246,6 +11412,7 @@ testChannelRelayLeave ps =
dan <## "#team: bob left the group (signed)",
eve <## "#team: bob left the group (signed)"
]
lastJobNotification bob `shouldReturn` False
-- verify relay1 member status is "left" on all clients that know bob
checkMemberStatus alice "bob" (Just "left")
@@ -13149,6 +13316,39 @@ relayConnIdToMember cc name = do
(Only connId : _) -> pure connId
_ -> fail $ "no relay connection to member " <> T.unpack name
agentSendBody :: TestCC -> ByteString -> ByteString -> IO ()
agentSendBody cc connId body = do
let ChatController {smpAgent} = chatController cc
runExceptT (sendMessages smpAgent [(connId, PQEncOff, MsgFlags False, vrValue body)]) >>= either (fail . show) (const $ pure ())
lastSndMsgFlags :: TestCC -> ByteString -> Int -> IO [T.Text]
lastSndMsgFlags cc connId n = do
rows <- withCCAgentTransaction cc $ \db ->
DB.query
db
"SELECT msg_flags FROM messages WHERE conn_id = ? AND internal_snd_id IS NOT NULL AND msg_type = ? ORDER BY internal_id DESC LIMIT ?"
(connId, Binary ("M" :: ByteString), n)
pure $ reverse $ map fromOnly rows
lastJobNotification :: TestCC -> IO Bool
lastJobNotification cc = do
[Only (BI ntf)] <- withCCTransaction cc $ \db ->
DB.query_ db "SELECT notification FROM delivery_jobs ORDER BY delivery_job_id DESC LIMIT 1"
pure ntf
duplicateMessageEventsCfg :: IO (TVar [ContactName], ChatConfig)
duplicateMessageEventsCfg = do
dups <- newTVarIO []
let hook _ evt = do
case evt of
Right (CEvtDuplicateGroupMessage _ GroupMember {localDisplayName}) -> atomically $ modifyTVar' dups (<> [localDisplayName])
_ -> pure ()
pure evt
pure (dups, testCfg {chatHooks = defaultChatHooks {eventHook = Just hook}})
setMainAppOff :: TestCC -> IO ()
setMainAppOff cc = atomically $ writeTVar (subscriptionMode $ chatController cc) SMOnlyCreate
itemSharedMsgId :: TestCC -> IO SharedMsgId
itemSharedMsgId cc = do
rows <- withCCTransaction cc $ \db ->