Skip to main content

xmtp_mls/groups/
message_list.rs

1use crate::context::XmtpSharedContext;
2use crate::groups::MlsGroup;
3use crate::messages::decoded_message::DecodedMessage;
4use crate::messages::enrichment::{EnrichMessageError, enrich_messages};
5use xmtp_db::DbQuery;
6use xmtp_db::group_message::{ContentType as DbContentType, MsgQueryArgs};
7use xmtp_db::prelude::QueryGroupMessage;
8
9impl<Context> MlsGroup<Context>
10where
11    Context: XmtpSharedContext,
12{
13    #[xmtp_common::mls_span]
14    pub fn find_messages_v2(
15        &self,
16        query: &MsgQueryArgs,
17    ) -> Result<Vec<DecodedMessage>, EnrichMessageError> {
18        let conn = self.context.db();
19        self.find_messages_v2_with_conn(query, conn)
20    }
21
22    #[xmtp_common::mls_span]
23    pub fn find_messages_v2_with_conn<C>(
24        &self,
25        query: &MsgQueryArgs,
26        conn: C,
27    ) -> Result<Vec<DecodedMessage>, EnrichMessageError>
28    where
29        C: QueryGroupMessage + DbQuery,
30    {
31        let initial_messages = conn.get_group_messages(
32            &self.group_id,
33            &filter_out_hidden_message_types_from_query(query),
34        )?;
35
36        enrich_messages(conn, &self.group_id, initial_messages)
37    }
38}
39
40fn filter_out_hidden_message_types_from_query(query: &MsgQueryArgs) -> MsgQueryArgs {
41    let mut new_query = query.clone();
42    let hidden_message_types = vec![
43        DbContentType::Reaction,
44        DbContentType::ReadReceipt,
45        DbContentType::DeleteMessage,
46    ];
47
48    let excluded_content_types = match &query.exclude_content_types {
49        Some(excluded) => {
50            let mut content_types = excluded.clone();
51            content_types.extend(
52                hidden_message_types
53                    .into_iter()
54                    .filter(|t| !excluded.contains(t)),
55            );
56            content_types
57        }
58        None => hidden_message_types,
59    };
60    new_query.exclude_content_types = Some(excluded_content_types);
61    new_query
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use crate::builder::ClientBuilder;
68    use crate::groups::QueryableContentFields;
69    use crate::messages::decoded_message::MessageBody;
70    use hex::ToHexExt;
71    use xmtp_common::time::now_ns;
72    use xmtp_content_types::ContentCodec;
73    use xmtp_content_types::test_utils::TestContentGenerator;
74    use xmtp_content_types::text::TextCodec;
75    use xmtp_cryptography::utils::generate_local_wallet;
76    use xmtp_db::Store;
77    use xmtp_db::group_message::{
78        ContentType as DbContentType, DeliveryStatus, GroupMessageKind, StoredGroupMessage,
79    };
80    use xmtp_proto::types::GroupId;
81    use xmtp_proto::xmtp::mls::message_contents::content_types::ReactionAction;
82    use xmtp_proto::xmtp::mls::message_contents::{ContentTypeId, EncodedContent};
83
84    async fn setup_test_group() -> (MlsGroup<impl XmtpSharedContext>, impl XmtpSharedContext) {
85        let client = ClientBuilder::new_test_client(&generate_local_wallet()).await;
86        let group = client.create_group(None, Default::default()).unwrap();
87
88        (group, client.context.clone())
89    }
90
91    fn create_test_message(
92        group_id: &GroupId,
93        message_id: Vec<u8>,
94        encoded_content: EncodedContent,
95        sent_at_ns: i64,
96        sender_inbox_id: String,
97    ) -> StoredGroupMessage {
98        let content_bytes = xmtp_content_types::encoded_content_to_bytes(encoded_content.clone());
99        let queryable_fields =
100            QueryableContentFields::try_from(encoded_content).unwrap_or_default();
101
102        StoredGroupMessage {
103            id: message_id,
104            group_id: *group_id,
105            decrypted_message_bytes: content_bytes,
106            sent_at_ns,
107            kind: GroupMessageKind::Application,
108            sender_installation_id: vec![1, 2, 3],
109            sender_inbox_id,
110            delivery_status: DeliveryStatus::Published,
111            content_type: queryable_fields.content_type,
112            version_major: queryable_fields.version_major,
113            version_minor: queryable_fields.version_minor,
114            authority_id: queryable_fields.authority_id,
115            reference_id: queryable_fields.reference_id,
116            sequence_id: 0,
117            envelope_hash: None,
118            expiry_ns: None,
119            expire_at_ns: None,
120            inserted_at_ns: 0,
121            should_push: true,
122            idempotency_key: sent_at_ns.to_string(),
123        }
124    }
125
126    // For tests that need malformed content
127    fn create_test_message_raw(
128        group_id: &GroupId,
129        message_id: Vec<u8>,
130        content: Vec<u8>,
131        sent_at_ns: i64,
132        sender_inbox_id: String,
133        content_type_id: Option<ContentTypeId>,
134        reference_id: Option<Vec<u8>>,
135    ) -> StoredGroupMessage {
136        let queryable_fields = content_type_id
137            .as_ref()
138            .map(|ct| {
139                (
140                    DbContentType::from(ct.type_id.clone()),
141                    ct.version_major as i32,
142                    ct.version_minor as i32,
143                    ct.authority_id.clone(),
144                )
145            })
146            .unwrap_or((DbContentType::Text, 1, 0, "xmtp.org".to_string()));
147
148        StoredGroupMessage {
149            id: message_id,
150            group_id: *group_id,
151            decrypted_message_bytes: content,
152            sent_at_ns,
153            kind: GroupMessageKind::Application,
154            sender_installation_id: vec![1, 2, 3],
155            sender_inbox_id,
156            delivery_status: DeliveryStatus::Published,
157            content_type: queryable_fields.0,
158            version_major: queryable_fields.1,
159            version_minor: queryable_fields.2,
160            authority_id: queryable_fields.3,
161            reference_id,
162            sequence_id: 0,
163            envelope_hash: None,
164            expiry_ns: None,
165            expire_at_ns: None,
166            inserted_at_ns: 0,
167            should_push: true,
168            idempotency_key: sent_at_ns.to_string(),
169        }
170    }
171
172    // ===== Assertion Helpers =====
173
174    fn assert_message_count(messages: &[DecodedMessage], expected: usize) {
175        assert_eq!(
176            messages.len(),
177            expected,
178            "Expected {} messages, got {}",
179            expected,
180            messages.len()
181        );
182    }
183
184    fn assert_text_content(message: &DecodedMessage, expected: &str) {
185        match &message.content {
186            MessageBody::Text(text) => assert_eq!(text.content, expected),
187            _ => panic!("Expected text message, got {:?}", message.content),
188        }
189    }
190
191    fn assert_reaction_count(message: &DecodedMessage, expected: usize) {
192        assert_eq!(
193            message.reactions.len(),
194            expected,
195            "Expected {} reactions, got {}",
196            expected,
197            message.reactions.len()
198        );
199    }
200
201    fn assert_has_reaction(message: &DecodedMessage, emoji: &str) {
202        assert!(
203            message.reactions.iter().any(|r| {
204                if let MessageBody::Reaction(reaction) = &r.content {
205                    reaction.content == emoji
206                } else {
207                    false
208                }
209            }),
210            "Expected reaction '{}' not found",
211            emoji
212        );
213    }
214
215    fn assert_reply_references(reply: &DecodedMessage, expected_ref_id: &[u8]) {
216        match &reply.content {
217            MessageBody::Reply(reply_body) => {
218                assert!(
219                    reply_body.in_reply_to.is_some(),
220                    "Reply should have in_reply_to populated"
221                );
222                let referenced = reply_body.in_reply_to.as_ref().unwrap();
223                assert_eq!(referenced.metadata.id, expected_ref_id);
224            }
225            _ => panic!("Expected reply message, got {:?}", reply.content),
226        }
227    }
228
229    fn assert_reply_has_no_reference(reply: &DecodedMessage) {
230        match &reply.content {
231            MessageBody::Reply(reply_body) => {
232                assert!(
233                    reply_body.in_reply_to.is_none(),
234                    "Reply should not have in_reply_to populated"
235                );
236            }
237            _ => panic!("Expected reply message, got {:?}", reply.content),
238        }
239    }
240
241    fn find_message_by_id<'a>(messages: &'a [DecodedMessage], id: &[u8]) -> &'a DecodedMessage {
242        messages
243            .iter()
244            .find(|m| m.metadata.id == id)
245            .unwrap_or_else(|| panic!("Message with id {:?} not found", id))
246    }
247
248    fn create_and_store_message<S>(
249        conn: &S,
250        group_id: &GroupId,
251        message_id: Vec<u8>,
252        content: EncodedContent,
253        timestamp_offset: i64,
254        sender: &str,
255    ) -> Vec<u8>
256    where
257        StoredGroupMessage: Store<S>,
258    {
259        let msg = create_test_message(
260            group_id,
261            message_id.clone(),
262            content,
263            now_ns() + timestamp_offset,
264            sender.to_string(),
265        );
266        msg.store(conn).unwrap();
267        message_id
268    }
269
270    // ===== Tests =====
271
272    #[xmtp_common::test]
273    async fn test_exclude_content_types_with_custom_exclusions() {
274        let (group, context) = setup_test_group().await;
275        let conn = context.db();
276
277        // Store a text message
278        create_and_store_message(
279            &conn,
280            &group.group_id,
281            vec![1],
282            TestContentGenerator::text_content("Hello World"),
283            0,
284            "sender1",
285        );
286
287        // Store a GroupUpdated message
288        create_and_store_message(
289            &conn,
290            &group.group_id,
291            vec![2],
292            TestContentGenerator::group_updated_content(vec!["inbox1".to_string()]),
293            1000,
294            "sender2",
295        );
296
297        // Query with default args - should return both messages
298        let messages = group.find_messages_v2(&MsgQueryArgs::default()).unwrap();
299        assert_message_count(&messages, 2);
300
301        // Query excluding Text messages - should only return the GroupUpdated message
302        let query = MsgQueryArgs {
303            exclude_content_types: Some(vec![DbContentType::Text]),
304            ..Default::default()
305        };
306        let messages = group.find_messages_v2(&query).unwrap();
307
308        assert_message_count(&messages, 1);
309        if let MessageBody::GroupUpdated(_) = &messages[0].content {
310            // Expected
311        } else {
312            panic!(
313                "Expected GroupUpdated message, got {:?}",
314                messages[0].content
315            );
316        }
317    }
318
319    #[xmtp_common::test]
320    async fn test_find_messages_no_reactions_or_replies() {
321        let (group, context) = setup_test_group().await;
322        let conn = context.db();
323
324        // Store two simple text messages
325        create_and_store_message(
326            &conn,
327            &group.group_id,
328            vec![1],
329            TestContentGenerator::text_content("Hello World"),
330            0,
331            "sender1",
332        );
333
334        create_and_store_message(
335            &conn,
336            &group.group_id,
337            vec![2],
338            TestContentGenerator::text_content("Another message"),
339            1000,
340            "sender2",
341        );
342
343        // Query and verify
344        let messages = group.find_messages_v2(&MsgQueryArgs::default()).unwrap();
345        assert_message_count(&messages, 2);
346        assert_text_content(&messages[0], "Hello World");
347        assert_text_content(&messages[1], "Another message");
348        assert_reaction_count(&messages[0], 0);
349        assert_reaction_count(&messages[1], 0);
350    }
351
352    #[xmtp_common::test]
353    async fn test_find_messages_with_reactions() {
354        let (group, context) = setup_test_group().await;
355        let conn = context.db();
356
357        // Store a text message
358        let msg_id = vec![1, 2, 3];
359        let msg_id_hex = msg_id.encode_hex();
360        create_and_store_message(
361            &conn,
362            &group.group_id,
363            msg_id.clone(),
364            TestContentGenerator::text_content("Hello World"),
365            0,
366            "sender1",
367        );
368
369        // Store reactions to the message
370        create_and_store_message(
371            &conn,
372            &group.group_id,
373            vec![4],
374            TestContentGenerator::reaction_content(&msg_id_hex, "👍", ReactionAction::Added),
375            1000,
376            "reactor1",
377        );
378
379        create_and_store_message(
380            &conn,
381            &group.group_id,
382            vec![5],
383            TestContentGenerator::reaction_content(&msg_id_hex, "❤️", ReactionAction::Added),
384            2000,
385            "reactor2",
386        );
387
388        // Query messages
389        let messages = group.find_messages_v2(&MsgQueryArgs::default()).unwrap();
390
391        // Should have 1 message (reactions are attached to the message, not returned separately)
392        assert_message_count(&messages, 1);
393
394        // Find the original message (should be first based on timestamp)
395        let original_msg = find_message_by_id(&messages, &msg_id);
396        assert_reaction_count(original_msg, 2);
397        assert_has_reaction(original_msg, "👍");
398        assert_has_reaction(original_msg, "❤️");
399    }
400
401    #[xmtp_common::test]
402    async fn test_find_messages_with_replies() {
403        let (group, context) = setup_test_group().await;
404        let conn = context.db();
405
406        // Store original message
407        let msg_id = vec![1, 2, 3];
408        let msg_id_hex = msg_id.encode_hex();
409        create_and_store_message(
410            &conn,
411            &group.group_id,
412            msg_id.clone(),
413            TestContentGenerator::text_content("Original message"),
414            0,
415            "sender1",
416        );
417
418        // Store a reply to the message
419        let reply_id = create_and_store_message(
420            &conn,
421            &group.group_id,
422            vec![4, 5, 6],
423            TestContentGenerator::reply_content(
424                &msg_id_hex,
425                TextCodec::content_type(),
426                b"This is a reply".to_vec(),
427            ),
428            1000,
429            "replier1",
430        );
431
432        // Query messages
433        let messages = group.find_messages_v2(&MsgQueryArgs::default()).unwrap();
434
435        assert_message_count(&messages, 2);
436
437        // Find the reply message and verify it references the original
438        let reply_msg = find_message_by_id(&messages, &reply_id);
439        assert_reply_references(reply_msg, &msg_id);
440
441        // Verify the referenced message content
442        if let MessageBody::Reply(reply) = &reply_msg.content {
443            let referenced_msg = reply.in_reply_to.as_ref().unwrap();
444            assert_text_content(referenced_msg, "Original message");
445        }
446    }
447
448    #[xmtp_common::test]
449    async fn test_find_messages_invalid_reply_reference() {
450        let (group, context) = setup_test_group().await;
451        let conn = context.db();
452
453        // Store a reply with invalid reference ID (not valid hex)
454        create_and_store_message(
455            &conn,
456            &group.group_id,
457            vec![1],
458            TestContentGenerator::reply_content(
459                "not-valid-hex!@#",
460                TextCodec::content_type(),
461                b"This is a reply".to_vec(),
462            ),
463            0,
464            "replier1",
465        );
466
467        // Query messages - should still return the reply but with None in_reply_to
468        let messages = group.find_messages_v2(&MsgQueryArgs::default()).unwrap();
469        assert_message_count(&messages, 1);
470
471        assert_reply_has_no_reference(&messages[0]);
472        if let MessageBody::Reply(reply) = &messages[0].content {
473            assert_eq!(reply.reference_id, "not-valid-hex!@#");
474        }
475    }
476
477    #[xmtp_common::test]
478    async fn test_find_messages_missing_reply_reference() {
479        let (group, context) = setup_test_group().await;
480        let conn = context.db();
481
482        // Store a reply with valid hex reference but non-existent message
483        let non_existent_id = vec![99, 99, 99];
484        create_and_store_message(
485            &conn,
486            &group.group_id,
487            vec![1],
488            TestContentGenerator::reply_content(
489                &non_existent_id.encode_hex(),
490                TextCodec::content_type(),
491                b"This is a reply".to_vec(),
492            ),
493            0,
494            "replier1",
495        );
496
497        // Query messages - should still return the reply but with None in_reply_to
498        let messages = group.find_messages_v2(&MsgQueryArgs::default()).unwrap();
499        assert_message_count(&messages, 1);
500        assert_reply_has_no_reference(&messages[0]);
501
502        if let MessageBody::Reply(reply) = &messages[0].content {
503            assert_eq!(reply.reference_id, non_existent_id.encode_hex());
504        }
505    }
506
507    #[xmtp_common::test]
508    async fn test_find_messages_undecodable_messages() {
509        let (group, context) = setup_test_group().await;
510        let conn = context.db();
511
512        // Store a valid text message
513        create_and_store_message(
514            &conn,
515            &group.group_id,
516            vec![1],
517            TestContentGenerator::text_content("Valid message"),
518            0,
519            "sender1",
520        );
521
522        // Store a message with invalid/malformed content (use Text type to avoid filtering)
523        create_and_store_message(
524            &conn,
525            &group.group_id,
526            vec![2],
527            TestContentGenerator::malformed_content_with_type(TextCodec::content_type()),
528            1000,
529            "sender2",
530        );
531
532        // Store a message with unknown content type
533        create_and_store_message(
534            &conn,
535            &group.group_id,
536            vec![3],
537            TestContentGenerator::invalid_content(),
538            2000,
539            "sender3",
540        );
541
542        // Query messages - malformed content still creates a message
543        let messages = group.find_messages_v2(&MsgQueryArgs::default()).unwrap();
544
545        // We should get all 3 messages even though some have malformed content
546        assert_message_count(&messages, 3);
547
548        // First message should be valid text
549        assert_text_content(&messages[0], "Valid message");
550
551        // Second message - since we're using EncodedContent, it gets decoded as text
552        assert_text_content(&messages[1], "malformed content for a known type");
553
554        if let MessageBody::Custom(content) = &messages[2].content {
555            assert_eq!(content.fallback, Some("Invalid message".to_string()));
556        } else {
557            panic!("Expected custom content for unknown type message");
558        }
559    }
560
561    #[xmtp_common::test]
562    async fn test_find_messages_invalid_reactions() {
563        let (group, context) = setup_test_group().await;
564        let conn = context.db();
565
566        // Store a text message
567        let msg_id = vec![1, 2, 3];
568        let msg_id_hex = msg_id.encode_hex();
569        create_and_store_message(
570            &conn,
571            &group.group_id,
572            msg_id.clone(),
573            TestContentGenerator::text_content("Hello World"),
574            0,
575            "sender1",
576        );
577
578        // Store a valid reaction
579        create_and_store_message(
580            &conn,
581            &group.group_id,
582            vec![4],
583            TestContentGenerator::reaction_content(&msg_id_hex, "👍", ReactionAction::Added),
584            1000,
585            "reactor1",
586        );
587
588        // Store an invalid reaction (malformed content)
589        let reaction_type = xmtp_content_types::reaction::ReactionCodec::content_type();
590        let invalid_reaction = create_test_message_raw(
591            &group.group_id,
592            vec![5],
593            b"invalid reaction bytes".to_vec(),
594            now_ns() + 2000,
595            "reactor2".to_string(),
596            Some(reaction_type),
597            Some(msg_id.clone()),
598        );
599        invalid_reaction.store(&conn).unwrap();
600
601        // Query messages
602        let messages = group.find_messages_v2(&MsgQueryArgs::default()).unwrap();
603
604        // Should have 1 message total
605        assert_message_count(&messages, 1);
606
607        // Find the original message
608        let original_msg = find_message_by_id(&messages, &msg_id);
609
610        // Should only have the valid reaction, invalid one should be filtered out
611        assert_reaction_count(original_msg, 1);
612        assert_has_reaction(original_msg, "👍");
613    }
614
615    #[xmtp_common::test]
616    async fn test_hidden_message_types_are_filtered() {
617        let (group, context) = setup_test_group().await;
618        let conn = context.db();
619
620        // Store a regular text message
621        create_and_store_message(
622            &conn,
623            &group.group_id,
624            vec![1],
625            TestContentGenerator::text_content("Hello World"),
626            0,
627            "sender1",
628        );
629
630        // Store a reaction (should be hidden)
631        let msg_id_hex = vec![1].encode_hex();
632        create_and_store_message(
633            &conn,
634            &group.group_id,
635            vec![2],
636            TestContentGenerator::reaction_content(&msg_id_hex, "👍", ReactionAction::Added),
637            1000,
638            "reactor1",
639        );
640
641        // Store a read receipt (should be hidden)
642        create_and_store_message(
643            &conn,
644            &group.group_id,
645            vec![3],
646            TestContentGenerator::read_receipt_content(),
647            2000,
648            "reader1",
649        );
650
651        // Store a delete message (should be hidden)
652        create_and_store_message(
653            &conn,
654            &group.group_id,
655            vec![4],
656            TestContentGenerator::delete_message_content(&msg_id_hex),
657            3000,
658            "deleter1",
659        );
660
661        // Store another text message
662        create_and_store_message(
663            &conn,
664            &group.group_id,
665            vec![5],
666            TestContentGenerator::text_content("Second message"),
667            4000,
668            "sender2",
669        );
670
671        // Query messages - should only return the 2 text messages
672        // Reactions, ReadReceipt, and DeleteMessage should be filtered out
673        let messages = group.find_messages_v2(&MsgQueryArgs::default()).unwrap();
674
675        // We expect only 2 messages (the text messages)
676        // Reaction is attached to message 1, ReadReceipt and DeleteMessage are filtered
677        assert_message_count(&messages, 2);
678        assert_text_content(&messages[0], "Hello World");
679        assert_text_content(&messages[1], "Second message");
680
681        // The reaction should be attached to the first message
682        assert_reaction_count(&messages[0], 1);
683        assert_has_reaction(&messages[0], "👍");
684    }
685
686    #[xmtp_common::test]
687    async fn test_find_messages_chain_of_replies() {
688        let (group, context) = setup_test_group().await;
689        let conn = context.db();
690
691        // Store original message
692        let msg1_id = vec![1];
693        let msg1_id_hex = msg1_id.encode_hex();
694        create_and_store_message(
695            &conn,
696            &group.group_id,
697            msg1_id.clone(),
698            TestContentGenerator::text_content("Original message"),
699            0,
700            "sender1",
701        );
702
703        // Store first reply (reply to original)
704        let msg2_id = vec![2];
705        let msg2_id_hex = msg2_id.encode_hex();
706        create_and_store_message(
707            &conn,
708            &group.group_id,
709            msg2_id.clone(),
710            TestContentGenerator::reply_content(
711                &msg1_id_hex,
712                TextCodec::content_type(),
713                b"First reply".to_vec(),
714            ),
715            1000,
716            "replier1",
717        );
718
719        // Store second reply (reply to first reply)
720        let msg3_id = vec![3];
721        create_and_store_message(
722            &conn,
723            &group.group_id,
724            msg3_id.clone(),
725            TestContentGenerator::reply_content(
726                &msg2_id_hex,
727                TextCodec::content_type(),
728                b"Second reply - reply to reply".to_vec(),
729            ),
730            2000,
731            "replier2",
732        );
733
734        // Query messages
735        let messages = group.find_messages_v2(&MsgQueryArgs::default()).unwrap();
736
737        assert_message_count(&messages, 3);
738
739        // Find the first reply
740        let first_reply = find_message_by_id(&messages, &msg2_id);
741        if let MessageBody::Reply(reply) = &first_reply.content {
742            // Should have reference to original message
743            assert!(reply.in_reply_to.is_some());
744            let referenced = reply.in_reply_to.as_ref().unwrap();
745            assert_eq!(referenced.metadata.id, msg1_id);
746
747            // The referenced message should NOT have its own in_reply_to (no recursive resolution)
748            if let MessageBody::Text(_) = &referenced.content {
749                // Expected: original message is just text
750            } else {
751                panic!("Expected text in first layer reference");
752            }
753        } else {
754            panic!("Expected reply message");
755        }
756
757        // Find the second reply (reply to reply)
758        let second_reply = find_message_by_id(&messages, &msg3_id);
759        if let MessageBody::Reply(reply) = &second_reply.content {
760            // Should have reference to first reply
761            assert!(reply.in_reply_to.is_some());
762            let referenced = reply.in_reply_to.as_ref().unwrap();
763            assert_eq!(referenced.metadata.id, msg2_id);
764
765            // The referenced message should be a Reply but its in_reply_to should be None (not resolved recursively)
766            if let MessageBody::Reply(inner_reply) = &referenced.content {
767                // We don't recursively resolve, so the inner reply's in_reply_to should be None
768                assert!(
769                    inner_reply.in_reply_to.is_none(),
770                    "Should not recursively resolve replies - got {:?}",
771                    inner_reply.in_reply_to
772                );
773            } else {
774                panic!("Expected reply in second layer reference");
775            }
776        } else {
777            panic!("Expected reply message");
778        }
779    }
780
781    #[xmtp_common::test]
782    async fn test_reply_with_custom_inner_content() {
783        let (group, context) = setup_test_group().await;
784        let conn = context.db();
785
786        // Store original message
787        let msg_id = vec![1, 2, 3];
788        let msg_id_hex = msg_id.encode_hex();
789        create_and_store_message(
790            &conn,
791            &group.group_id,
792            msg_id.clone(),
793            TestContentGenerator::text_content("Original message"),
794            0,
795            "sender1",
796        );
797
798        // Create a custom/unknown content type
799        let custom_content_type = ContentTypeId {
800            authority_id: "custom.org".to_string(),
801            type_id: "custom/payload".to_string(),
802            version_major: 1,
803            version_minor: 0,
804        };
805
806        // Store a reply with custom inner content
807        let reply_id = create_and_store_message(
808            &conn,
809            &group.group_id,
810            vec![4, 5, 6],
811            TestContentGenerator::reply_content(
812                &msg_id_hex,
813                custom_content_type,
814                b"custom payload data".to_vec(),
815            ),
816            1000,
817            "replier1",
818        );
819
820        // Query messages
821        let messages = group.find_messages_v2(&MsgQueryArgs::default()).unwrap();
822        assert_message_count(&messages, 2);
823
824        // Find the reply message
825        let reply_msg = find_message_by_id(&messages, &reply_id);
826
827        // Verify the message is decoded as Reply (not Custom)
828        if let MessageBody::Reply(reply) = &reply_msg.content {
829            // The reply should reference the original message
830            assert!(reply.in_reply_to.is_some());
831            let referenced = reply.in_reply_to.as_ref().unwrap();
832            assert_eq!(referenced.metadata.id, msg_id);
833
834            // The inner content should be Custom (since we used an unknown content type)
835            if let MessageBody::Custom(custom) = reply.content.as_ref() {
836                assert_eq!(custom.r#type.as_ref().unwrap().type_id, "custom/payload");
837                assert_eq!(custom.content, b"custom payload data");
838            } else {
839                panic!(
840                    "Expected Custom inner content in Reply, got {:?}",
841                    reply.content
842                );
843            }
844        } else {
845            panic!("Expected Reply message, got {:?}", reply_msg.content);
846        }
847    }
848}