1use xmtp_db::{
4 ConnectionError, DbQuery,
5 consent_record::{
6 ConsentChange, ConsentKey, ConsentState as StoredConsentState, ConsentType, ConsentValue,
7 StoredConsentRecord,
8 },
9 group_message::StoredGroupMessage,
10 user_preferences::StoredUserPreferences,
11};
12use xmtp_mls_group_state::group_metadata::DmMembers;
13use xmtp_proto::types::GroupId;
14
15use xmtp_cryptography::secret::Secret;
16use xmtp_events::{
17 ClientEvent, ConsentChanged, ConsentEntityKind, ConsentState, DeletionCause, EventContext,
18 EventWriter, HmacKeysUpdated, MessageDeleted, MessageRef,
19};
20
21#[derive(Clone, Debug)]
24pub enum PreferenceUpdate {
25 Consent(StoredConsentRecord),
26 Hmac { key: Secret, cycled_at_ns: i64 },
27}
28
29#[derive(Clone, Debug)]
30pub enum InternalEvent {
31 GroupJoined {
32 group_id: GroupId,
33 is_sync: bool,
34 },
35 MessageStored {
36 group_id: GroupId,
37 message_id: Vec<u8>,
38 expires_at_ns: Option<i64>,
39 is_sync: bool,
40 },
41 OutboxChanged,
43 TaskScheduled,
44 NotificationSettingsChanged,
45 PreferencesChanged {
47 updates: Vec<PreferenceUpdate>,
48 },
49 MessagesDeleted(DeletedMessages),
52 MessagesExpired(DeletedMessages),
55}
56
57#[derive(Clone, Debug)]
59pub struct DeletedMessages {
60 pub messages: Vec<StoredGroupMessage>,
61 pub deleted_by_inbox_id: Option<String>,
64}
65
66impl InternalEvent {
67 pub fn is_message_deletion(&self) -> bool {
69 matches!(self, Self::MessagesDeleted(_) | Self::MessagesExpired(_))
70 }
71}
72
73pub fn emit_consent_changes(
78 ctx: &impl crate::context::CoreContext,
79 events: &impl EventWriter<InternalEvent>,
80 db: &impl DbQuery,
81 changes: &[ConsentChange],
82) -> Result<(), ConnectionError> {
83 let mut net: Vec<(&ConsentKey, StoredConsentState, ConsentValue)> = vec![];
85 for change in changes {
86 match net.iter_mut().find(|(key, ..)| **key == change.key) {
87 Some(entry) => entry.2 = change.value,
88 None => net.push((&change.key, change.before, change.value)),
89 }
90 }
91 let mut legacy = vec![];
92 let mut state_changed = false;
93 for (key, before, value) in net {
94 let changed = before != value.state;
95 state_changed |= changed;
96 for (entity_type, entity, context) in app_entities(db, ctx.inbox_id(), key)? {
97 if changed {
98 let kind = match entity_type {
99 ConsentType::ConversationId => ConsentEntityKind::Conversation,
100 ConsentType::InboxId => ConsentEntityKind::Inbox,
101 };
102 let event = ClientEvent::ConsentChanged(ConsentChanged {
103 entity_kind: kind,
104 entity: entity.clone(),
105 state: match value.state {
106 StoredConsentState::Unknown => ConsentState::Unknown,
107 StoredConsentState::Allowed => ConsentState::Allowed,
108 StoredConsentState::Denied => ConsentState::Denied,
109 },
110 });
111 events.emit_with_context(Some(event), None, context);
112 }
113 legacy.push(PreferenceUpdate::Consent(StoredConsentRecord {
114 entity_type,
115 state: value.state,
116 entity,
117 consented_at_ns: value.consented_at_ns,
118 owed_seq: 0,
119 }));
120 }
121 }
122 if state_changed {
123 ctx.task_channels().mark_notification_changed();
125 }
126 if !legacy.is_empty() {
127 events.emit(
128 None,
129 Some(InternalEvent::PreferencesChanged { updates: legacy }),
130 );
131 }
132 if changes.iter().any(|change| change.marked) {
133 events.emit(None, Some(InternalEvent::OutboxChanged));
134 }
135 Ok(())
136}
137
138fn app_entities(
142 db: &impl DbQuery,
143 own_inbox: &str,
144 key: &ConsentKey,
145) -> Result<Vec<(ConsentType, String, EventContext)>, ConnectionError> {
146 let mut entities = vec![];
147 let held = match key.entity_type {
148 ConsentType::InboxId => {
149 entities.push((
150 ConsentType::InboxId,
151 key.entity.clone(),
152 EventContext::default(),
153 ));
154 let dm_id = DmMembers {
155 member_one_inbox_id: own_inbox,
156 member_two_inbox_id: key.entity.as_str(),
157 };
158 db.find_dm_by_dm_id(&dm_id.to_string())?
159 }
160 ConsentType::ConversationId => {
161 let held = match key.group_id() {
163 Some(id) => db.find_group(&id)?,
164 None => None,
165 };
166 if held.is_none() {
167 entities.push((
168 ConsentType::ConversationId,
169 key.entity.clone(),
170 EventContext::default(),
171 ));
172 }
173 held
174 }
175 };
176 if let Some(group) = held
177 && !group.conversation_type.is_virtual()
178 && ConsentKey::of_group(&group, own_inbox).as_ref() == Some(key)
179 {
180 let context = dm_event_context(group.dm_id.as_deref());
181 entities.push((ConsentType::ConversationId, hex::encode(group.id), context));
182 }
183 Ok(entities)
184}
185
186pub fn emit_root_key_changed(
189 events: &impl EventWriter<InternalEvent>,
190 db: &impl DbQuery,
191 marked: bool,
192) -> Result<(), xmtp_db::StorageError> {
193 let preferences = StoredUserPreferences::load(db)?;
194 let updates = preferences
195 .hmac_key
196 .map(|key| PreferenceUpdate::Hmac {
197 key: key.as_slice().to_vec().into(),
198 cycled_at_ns: preferences.hmac_key_cycled_at_ns.unwrap_or_default(),
199 })
200 .into_iter()
201 .collect();
202 events.emit(
203 Some(ClientEvent::HmacKeysUpdated(HmacKeysUpdated)),
204 Some(InternalEvent::PreferencesChanged { updates }),
205 );
206 if marked {
207 events.emit(None, Some(InternalEvent::OutboxChanged));
208 }
209 Ok(())
210}
211
212pub fn dm_event_context(dm_id: Option<&str>) -> EventContext {
214 EventContext {
215 dm_identifier: dm_id.map(|id| id.as_bytes().to_vec()),
216 ..Default::default()
217 }
218}
219
220pub fn emit_deleted_messages(
223 writer: &impl EventWriter<InternalEvent>,
224 messages: Vec<StoredGroupMessage>,
225 cause: DeletionCause,
226 deleted_by_inbox_id: Option<String>,
227 db: &impl DbQuery,
228) -> Result<(), ConnectionError> {
229 if messages.is_empty() {
230 return Ok(());
231 }
232 for message in &messages {
233 let group = db.find_group(&message.group_id)?;
234 if group
235 .as_ref()
236 .is_some_and(|group| group.conversation_type.is_virtual())
237 {
238 continue;
239 }
240 let reference = MessageRef {
241 group_id: message.group_id.to_vec(),
242 message_id: message.id.clone(),
243 };
244 let event = ClientEvent::MessageDeleted(MessageDeleted {
245 group_id: reference.group_id,
246 message_id: reference.message_id,
247 cause,
248 });
249 let context = dm_event_context(group.as_ref().and_then(|group| group.dm_id.as_deref()));
250 writer.emit_with_context(Some(event), None, context);
251 }
252 let now = xmtp_common::time::now_ns();
255 let (expired, live): (Vec<_>, Vec<_>) = messages
256 .into_iter()
257 .partition(|message| message.expire_at_ns.is_some_and(|at| at <= now));
258 if !live.is_empty() {
259 writer.emit(
260 None,
261 Some(InternalEvent::MessagesDeleted(DeletedMessages {
262 messages: live,
263 deleted_by_inbox_id: deleted_by_inbox_id.clone(),
264 })),
265 );
266 }
267 if !expired.is_empty() {
268 let expired = expired
269 .into_iter()
270 .map(|message| StoredGroupMessage {
271 decrypted_message_bytes: Vec::new(),
272 ..message
273 })
274 .collect();
275 writer.emit(
276 None,
277 Some(InternalEvent::MessagesExpired(DeletedMessages {
278 messages: expired,
279 deleted_by_inbox_id,
280 })),
281 );
282 }
283 Ok(())
284}
285
286pub(crate) fn emit_expired_messages(
287 writer: &impl EventWriter<InternalEvent>,
288 messages: Vec<StoredGroupMessage>,
289 db: &impl DbQuery,
290) -> Result<(), ConnectionError> {
291 if messages.is_empty() {
292 return Ok(());
293 }
294 for message in &messages {
295 let group = db.find_group(&message.group_id)?;
296 if group
297 .as_ref()
298 .is_some_and(|group| group.conversation_type.is_virtual())
299 {
300 continue;
301 }
302 let context = dm_event_context(group.as_ref().and_then(|group| group.dm_id.as_deref()));
303 writer.emit_with_context(
304 Some(ClientEvent::MessageExpired(MessageRef {
305 group_id: message.group_id.to_vec(),
306 message_id: message.id.clone(),
307 })),
308 None,
309 context,
310 );
311 }
312 writer.emit(
313 None,
314 Some(InternalEvent::MessagesExpired(DeletedMessages {
315 messages,
316 deleted_by_inbox_id: None,
317 })),
318 );
319 Ok(())
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325 use crate::{
326 context::CoreContext,
327 test_context::{real_context, stored_msg},
328 };
329 use xmtp_db::{
330 group::{GroupMembershipState, StoredGroup},
331 prelude::*,
332 };
333 use xmtp_events::{EventBus, EventFilter, EventKind};
334 use xmtp_proto::types::ConversationType;
335
336 #[xmtp_common::test(unwrap_try = true)]
338 async fn stitched_dm_delete_and_expiry_reach_group_filter() {
339 use xmtp_proto::types::Cursor;
340
341 let context = real_context().await;
342 let db = context.db();
343 let [ga, gb, gc] = [71, 72, 73].map(|id| GroupId::from([id; 16]));
344 for (id, dm_id) in [(ga, "stitched"), (gb, "stitched"), (gc, "other")] {
345 StoredGroup::builder()
346 .id(id)
347 .created_at_ns(xmtp_common::time::now_ns())
348 .membership_state(GroupMembershipState::Allowed)
349 .added_by_inbox_id(context.inbox_id())
350 .conversation_type(ConversationType::Dm)
351 .dm_id(Some(dm_id.to_string()))
352 .build()?
353 .store(&db)?;
354 }
355 let messages = [stored_msg(Cursor(1), gb), stored_msg(Cursor(2), gc)];
356 let bus = EventBus::<InternalEvent>::new();
357 let mut filter = EventFilter::new([EventKind::MessageDeleted, EventKind::MessageExpired]);
358 filter.group_ids = Some(vec![ga.to_vec()]);
359 filter.dm_identifiers = vec![db.find_group(&ga)?.unwrap().dm_id.unwrap().into_bytes()];
360 let public = bus.subscribe(filter, Some(10));
361 emit_deleted_messages(
362 &bus,
363 messages.to_vec(),
364 DeletionCause::DeletedLocally,
365 None,
366 &db,
367 )?;
368 emit_expired_messages(&bus, messages.to_vec(), &db)?;
369 let events: Vec<_> = public
370 .drain()
371 .into_iter()
372 .filter_map(|e| e.client)
373 .collect();
374 assert_eq!(events.len(), 2);
375 assert!(matches!(&events[0], ClientEvent::MessageDeleted(deleted)
376 if deleted.group_id == gb.to_vec()));
377 assert!(matches!(&events[1], ClientEvent::MessageExpired(expired)
378 if expired.group_id == gb.to_vec()));
379 }
380
381 #[xmtp_common::test(unwrap_try = true)]
387 async fn consent_events() {
388 use xmtp_db::consent_record::{ConsentChange, ConsentKey, ConsentValue};
389 let context = real_context().await;
390 let db = context.db();
391 let own = context.inbox_id();
392 let store = |id: u8, peer: Option<&str>, kind, state| {
393 let mut pair = peer.map(|peer| [own.to_string(), peer.to_string()]);
394 if let Some(pair) = &mut pair {
395 pair.sort();
396 }
397 StoredGroup::builder()
398 .id(GroupId::from([id; 16]))
399 .created_at_ns(1)
400 .membership_state(state)
401 .added_by_inbox_id(own)
402 .conversation_type(kind)
403 .dm_id(pair.map(|pair| format!("dm:{}", pair.join(":"))))
404 .build()
405 .unwrap()
406 .store(&db)
407 .unwrap();
408 GroupId::from([id; 16])
409 };
410 let restored = store(
411 1,
412 Some("peer-two"),
413 ConversationType::Dm,
414 GroupMembershipState::Restored,
415 );
416 let held_dm = store(
417 2,
418 Some("peer-three"),
419 ConversationType::Dm,
420 GroupMembershipState::Allowed,
421 );
422 let sync = store(
423 3,
424 None,
425 ConversationType::Sync,
426 GroupMembershipState::Allowed,
427 );
428 let change = |key: ConsentKey, before, state| ConsentChange {
429 key,
430 before,
431 value: ConsentValue {
432 state,
433 consented_at_ns: 1,
434 },
435 marked: false,
436 };
437 let bus = EventBus::<InternalEvent>::new();
438 let public = bus.subscribe(EventFilter::new([EventKind::ConsentChanged]), Some(16));
439 let legacy = bus.subscribe(
440 EventFilter::default()
441 .with_internal(|event| matches!(event, InternalEvent::PreferencesChanged { .. })),
442 Some(16),
443 );
444 let named = |events: Vec<xmtp_events::EventEnvelope<InternalEvent>>| -> Vec<String> {
445 events
446 .into_iter()
447 .filter_map(|event| match event.client {
448 Some(ClientEvent::ConsentChanged(change)) => Some(change.entity),
449 _ => None,
450 })
451 .collect()
452 };
453 use StoredConsentState::*;
454 for (changes, expected) in [
455 (
456 vec![change(ConsentKey::inbox("peer-one"), Unknown, Denied)],
457 vec!["peer-one".to_string()],
458 ),
459 (
460 vec![change(ConsentKey::inbox("peer-two"), Unknown, Denied)],
461 vec!["peer-two".to_string(), hex::encode(restored)],
462 ),
463 (
464 vec![change(ConsentKey::group(held_dm.as_ref()), Unknown, Denied)],
465 vec![],
466 ),
467 (
468 vec![change(ConsentKey::group(sync.as_ref()), Unknown, Denied)],
469 vec![],
470 ),
471 (
472 vec![change(ConsentKey::inbox("new"), Unknown, Unknown)],
473 vec![],
474 ),
475 (
476 vec![change(
477 ConsentKey::parse(ConsentType::ConversationId, "opaque").unwrap(),
478 Unknown,
479 Denied,
480 )],
481 vec!["opaque".to_string()],
482 ),
483 ] {
484 emit_consent_changes(&context, &bus, &db, &changes)?;
485 assert_eq!(named(public.drain()), expected, "{changes:?}");
486 }
487 let entities: Vec<Vec<String>> = legacy
488 .drain()
489 .into_iter()
490 .filter_map(|event| match event.internal {
491 Some(InternalEvent::PreferencesChanged { updates }) => Some(
492 updates
493 .into_iter()
494 .filter_map(|update| match update {
495 PreferenceUpdate::Consent(record) => Some(record.entity),
496 PreferenceUpdate::Hmac { .. } => None,
497 })
498 .collect(),
499 ),
500 _ => None,
501 })
502 .collect();
503 assert_eq!(
504 entities,
505 vec![
506 vec!["peer-one".to_string()],
507 vec!["peer-two".to_string(), hex::encode(restored)],
508 vec!["new".to_string()],
509 vec!["opaque".to_string()],
510 ]
511 );
512 }
513}