Skip to main content

im_core/groups/
dto.rs

1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use serde_json::Value;
3
4#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5pub struct GroupReadResult {
6    pub group: Option<GroupSnapshot>,
7    pub groups: Vec<GroupSummary>,
8    pub members: Vec<GroupMember>,
9    #[serde(default)]
10    pub resolved_member: Option<GroupMemberResolution>,
11    pub messages: crate::ids::Page<crate::messages::Message>,
12    pub total: Option<u32>,
13    pub source: Option<String>,
14    #[serde(skip)]
15    raw_response: Option<Value>,
16    pub warnings: Vec<String>,
17}
18
19impl GroupReadResult {
20    pub(crate) fn from_raw_response(raw: Value, warnings: Vec<String>) -> Self {
21        let warnings = merge_raw_warnings(raw.get("warnings"), warnings);
22        let group = group_snapshot_from_response(&raw);
23        let groups = values_from_array(raw.get("groups"))
24            .into_iter()
25            .filter_map(group_summary_from_value)
26            .collect();
27        let members = values_from_array(raw.get("members"))
28            .into_iter()
29            .filter_map(group_member_from_value)
30            .collect();
31        let message_items = values_from_array(raw.get("messages"))
32            .into_iter()
33            .filter_map(group_message_from_value)
34            .collect::<Vec<_>>();
35        let messages = crate::ids::Page {
36            items: message_items,
37            next_cursor: cursor_from_value(
38                raw.get("next_cursor").or_else(|| raw.get("next_since_seq")),
39            ),
40            has_more: bool_value(raw.get("has_more")),
41        };
42        Self {
43            group,
44            groups,
45            members,
46            resolved_member: None,
47            messages,
48            total: u32_value(raw.get("total")),
49            source: optional_string(raw.get("source")),
50            raw_response: Some(raw),
51            warnings,
52        }
53    }
54
55    pub fn response_json(&self) -> Option<&Value> {
56        self.raw_response.as_ref()
57    }
58
59    pub(crate) fn raw_response(&self) -> Option<&Value> {
60        self.raw_response.as_ref()
61    }
62
63    pub(crate) fn merge_group_snapshot_from(&mut self, other: &Self) {
64        if other.group.is_some() {
65            self.group = other.group.clone();
66        }
67        self.warnings.extend(other.warnings.iter().cloned());
68    }
69
70    pub(crate) fn merge_group_members_from(&mut self, other: &Self) {
71        self.members = other.members.clone();
72        if other.total.is_some() {
73            self.total = other.total;
74        }
75        self.warnings.extend(other.warnings.iter().cloned());
76    }
77
78    pub(crate) fn push_warning(&mut self, warning: impl Into<String>) {
79        self.warnings.push(warning.into());
80    }
81
82    pub(crate) fn replace_messages(
83        &mut self,
84        messages: crate::ids::Page<crate::messages::Message>,
85    ) {
86        self.messages = messages;
87    }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct GroupCreateRequest {
92    pub name: String,
93    pub description: Option<String>,
94    pub avatar_uri: Option<String>,
95    pub discoverability: Option<GroupDiscoverability>,
96    pub admission_mode: Option<GroupAdmissionMode>,
97    pub message_security_profile: Option<GroupMessageSecurityProfile>,
98    #[serde(default)]
99    pub security: GroupSecurityRequirement,
100    pub e2ee: bool,
101    pub slug: Option<String>,
102    pub goal: Option<String>,
103    pub rules: Option<String>,
104    pub message_prompt: Option<String>,
105    pub doc_url: Option<String>,
106    pub attachments_allowed: Option<bool>,
107    pub max_members: Option<GroupMemberLimit>,
108    pub member_max_messages: Option<i64>,
109    pub member_max_total_chars: Option<i64>,
110}
111
112impl GroupCreateRequest {
113    pub fn new(name: impl Into<String>) -> Self {
114        Self {
115            name: name.into(),
116            description: None,
117            avatar_uri: None,
118            discoverability: None,
119            admission_mode: None,
120            message_security_profile: None,
121            security: GroupSecurityRequirement::default(),
122            e2ee: false,
123            slug: None,
124            goal: None,
125            rules: None,
126            message_prompt: None,
127            doc_url: None,
128            attachments_allowed: None,
129            max_members: None,
130            member_max_messages: None,
131            member_max_total_chars: None,
132        }
133    }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137pub struct GroupJoinRequest {
138    pub group: crate::ids::GroupRef,
139    pub reason_text: Option<String>,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct GroupLeaveRequest {
144    pub group: crate::ids::GroupRef,
145    pub reason_text: Option<String>,
146    #[serde(default)]
147    pub security: GroupSecurityRequirement,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct GroupMemberMutationRequest {
152    pub group: crate::ids::GroupRef,
153    pub member: GroupMemberRef,
154    pub role: Option<GroupMemberRole>,
155    pub reason_text: Option<String>,
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub leave_request_id: Option<String>,
158    #[serde(default)]
159    pub security: GroupSecurityRequirement,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct GroupKeyPackagePublishRequest {
164    pub purpose: GroupKeyPackagePurpose,
165    pub group: Option<crate::ids::GroupRef>,
166    pub device_id: Option<String>,
167    pub key_package_id: Option<String>,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct GroupKeyPackagePublishResult {
172    pub owner_did: crate::ids::Did,
173    pub device_id: String,
174    pub key_package_id: String,
175    pub purpose: GroupKeyPackagePurpose,
176    pub group: Option<crate::ids::GroupRef>,
177    pub raw_response: Value,
178    pub warnings: Vec<String>,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182pub struct GroupE2eeProcessLeaveRequest {
183    pub group: crate::ids::GroupRef,
184    pub member: GroupMemberRef,
185    pub leave_request_id: String,
186    pub reason_text: Option<String>,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190pub struct GroupE2eeUpdateKeyRequest {
191    pub group: crate::ids::GroupRef,
192    pub member: GroupMemberRef,
193    pub device_id: Option<String>,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197pub struct GroupE2eeRecoverMemberRequest {
198    pub group: crate::ids::GroupRef,
199    pub member: GroupMemberRef,
200    pub device_id: Option<String>,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub enum GroupKeyPackagePurpose {
205    Normal,
206    Recovery,
207    Update,
208    Custom(String),
209}
210
211impl GroupKeyPackagePurpose {
212    pub fn parse(input: impl Into<String>) -> crate::ImResult<Self> {
213        parse_group_token(input, "purpose", |value| match value {
214            "normal" => Self::Normal,
215            "recovery" => Self::Recovery,
216            "update" => Self::Update,
217            custom => Self::Custom(custom.to_string()),
218        })
219    }
220
221    pub fn as_str(&self) -> &str {
222        match self {
223            Self::Normal => "normal",
224            Self::Recovery => "recovery",
225            Self::Update => "update",
226            Self::Custom(value) => value.as_str(),
227        }
228    }
229}
230
231impl Default for GroupKeyPackagePurpose {
232    fn default() -> Self {
233        Self::Normal
234    }
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
238#[serde(transparent)]
239pub struct GroupMemberRef(String);
240
241impl GroupMemberRef {
242    pub fn parse(input: impl AsRef<str>, default_domain: &str) -> crate::ImResult<Self> {
243        let value = input.as_ref().trim();
244        if value.is_empty() {
245            return Err(crate::ImError::invalid_input(
246                Some("member".to_string()),
247                "group member must not be empty",
248            ));
249        }
250        if value.starts_with("did:") {
251            return crate::ids::Did::parse(value).map(Self::from);
252        }
253        crate::ids::Handle::parse(value, default_domain).map(Self::from)
254    }
255
256    pub fn as_str(&self) -> &str {
257        &self.0
258    }
259
260    pub fn is_did(&self) -> bool {
261        self.0.starts_with("did:")
262    }
263
264    pub fn as_did(&self) -> crate::ImResult<crate::ids::Did> {
265        crate::ids::Did::parse(self.as_str())
266    }
267}
268
269impl From<crate::ids::Did> for GroupMemberRef {
270    fn from(did: crate::ids::Did) -> Self {
271        Self(did.as_str().to_string())
272    }
273}
274
275impl From<crate::ids::Handle> for GroupMemberRef {
276    fn from(handle: crate::ids::Handle) -> Self {
277        Self(handle.as_str().to_string())
278    }
279}
280
281impl From<crate::ids::PeerRef> for GroupMemberRef {
282    fn from(peer: crate::ids::PeerRef) -> Self {
283        Self(peer.as_str().to_string())
284    }
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288pub struct GroupMemberResolution {
289    pub did: crate::ids::Did,
290    pub handle: Option<crate::ids::Handle>,
291}
292
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
294pub struct GroupProfilePatch {
295    pub name: Option<String>,
296    pub description: Option<String>,
297    pub avatar_uri: Option<String>,
298    pub discoverability: Option<GroupDiscoverability>,
299    pub slug: Option<String>,
300    pub goal: Option<String>,
301    pub rules: Option<String>,
302    pub message_prompt: Option<String>,
303    pub doc_url: Option<String>,
304}
305
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
307pub struct GroupPolicyPatch {
308    pub admission_mode: Option<GroupAdmissionMode>,
309    pub attachments_allowed: Option<bool>,
310    pub max_members: Option<GroupMemberLimit>,
311    pub member_max_messages: Option<i64>,
312    pub member_max_total_chars: Option<i64>,
313}
314
315#[derive(Debug, Clone, PartialEq, Eq)]
316pub enum GroupDiscoverability {
317    Private,
318    Public,
319    Unlisted,
320    Custom(String),
321}
322
323impl GroupDiscoverability {
324    pub fn parse(input: impl Into<String>) -> crate::ImResult<Self> {
325        parse_group_token(input, "discoverability", |value| match value {
326            "private" => Self::Private,
327            "public" => Self::Public,
328            "unlisted" => Self::Unlisted,
329            custom => Self::Custom(custom.to_string()),
330        })
331    }
332
333    pub fn parse_optional(input: impl AsRef<str>) -> crate::ImResult<Option<Self>> {
334        parse_optional_group_token(input, Self::parse)
335    }
336
337    pub fn as_str(&self) -> &str {
338        match self {
339            Self::Private => "private",
340            Self::Public => "public",
341            Self::Unlisted => "unlisted",
342            Self::Custom(value) => value.as_str(),
343        }
344    }
345}
346
347#[derive(Debug, Clone, PartialEq, Eq)]
348pub enum GroupAdmissionMode {
349    OpenJoin,
350    InviteOnly,
351    ApprovalRequired,
352    Closed,
353    Custom(String),
354}
355
356impl GroupAdmissionMode {
357    pub fn parse(input: impl Into<String>) -> crate::ImResult<Self> {
358        parse_group_token(input, "admission_mode", |value| match value {
359            "open-join" | "open" => Self::OpenJoin,
360            "invite-only" => Self::InviteOnly,
361            "approval" | "approval-required" => Self::ApprovalRequired,
362            "closed" => Self::Closed,
363            custom => Self::Custom(custom.to_string()),
364        })
365    }
366
367    pub fn parse_optional(input: impl AsRef<str>) -> crate::ImResult<Option<Self>> {
368        parse_optional_group_token(input, Self::parse)
369    }
370
371    pub fn as_str(&self) -> &str {
372        match self {
373            Self::OpenJoin => "open-join",
374            Self::InviteOnly => "invite-only",
375            Self::ApprovalRequired => "approval",
376            Self::Closed => "closed",
377            Self::Custom(value) => value.as_str(),
378        }
379    }
380}
381
382#[derive(Debug, Clone, PartialEq, Eq)]
383pub enum GroupMessageSecurityProfile {
384    TransportProtected,
385    GroupE2ee,
386    Custom(String),
387}
388
389impl GroupMessageSecurityProfile {
390    pub fn parse(input: impl Into<String>) -> crate::ImResult<Self> {
391        parse_group_token(input, "message_security_profile", |value| match value {
392            "transport-protected" => Self::TransportProtected,
393            "group-e2ee" => Self::GroupE2ee,
394            custom => Self::Custom(custom.to_string()),
395        })
396    }
397
398    pub fn parse_optional(input: impl AsRef<str>) -> crate::ImResult<Option<Self>> {
399        parse_optional_group_token(input, Self::parse)
400    }
401
402    pub fn as_str(&self) -> &str {
403        match self {
404            Self::TransportProtected => "transport-protected",
405            Self::GroupE2ee => "group-e2ee",
406            Self::Custom(value) => value.as_str(),
407        }
408    }
409}
410
411#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
412#[serde(rename_all = "snake_case")]
413pub enum GroupSecurityRequirement {
414    #[default]
415    Default,
416    Required,
417}
418
419impl GroupSecurityRequirement {
420    pub fn required(self) -> bool {
421        matches!(self, Self::Required)
422    }
423}
424
425#[derive(Debug, Clone, PartialEq, Eq)]
426pub enum GroupMemberRole {
427    Owner,
428    Admin,
429    Member,
430    Custom(String),
431}
432
433impl GroupMemberRole {
434    pub fn parse(input: impl Into<String>) -> crate::ImResult<Self> {
435        parse_group_token(input, "role", |value| match value {
436            "owner" => Self::Owner,
437            "admin" => Self::Admin,
438            "member" => Self::Member,
439            custom => Self::Custom(custom.to_string()),
440        })
441    }
442
443    pub fn parse_optional(input: impl AsRef<str>) -> crate::ImResult<Option<Self>> {
444        parse_optional_group_token(input, Self::parse)
445    }
446
447    pub fn as_str(&self) -> &str {
448        match self {
449            Self::Owner => "owner",
450            Self::Admin => "admin",
451            Self::Member => "member",
452            Self::Custom(value) => value.as_str(),
453        }
454    }
455}
456
457#[derive(Debug, Clone, Copy, PartialEq, Eq)]
458pub struct GroupMemberLimit(u32);
459
460impl GroupMemberLimit {
461    pub fn new(value: u32) -> crate::ImResult<Self> {
462        if value == 0 {
463            return Err(crate::ImError::invalid_input(
464                Some("max_members".to_string()),
465                "max_members must be greater than zero",
466            ));
467        }
468        Ok(Self(value))
469    }
470
471    pub fn parse(input: impl Into<String>) -> crate::ImResult<Self> {
472        let input = input.into();
473        let trimmed = input.trim();
474        if trimmed.is_empty() {
475            return Err(crate::ImError::invalid_input(
476                Some("max_members".to_string()),
477                "max_members must not be empty",
478            ));
479        }
480        let value = trimmed.parse::<u32>().map_err(|_| {
481            crate::ImError::invalid_input(
482                Some("max_members".to_string()),
483                "max_members must be an unsigned integer",
484            )
485        })?;
486        Self::new(value)
487    }
488
489    pub fn parse_optional(input: impl AsRef<str>) -> crate::ImResult<Option<Self>> {
490        let trimmed = input.as_ref().trim();
491        if trimmed.is_empty() {
492            return Ok(None);
493        }
494        Self::parse(trimmed.to_string()).map(Some)
495    }
496
497    pub fn as_u32(self) -> u32 {
498        self.0
499    }
500
501    pub fn to_protocol_string(self) -> String {
502        self.0.to_string()
503    }
504}
505
506#[cfg(test)]
507mod group_domain_type_tests {
508    use super::*;
509
510    #[test]
511    fn group_policy_types_parse_known_and_custom_protocol_values() {
512        assert_eq!(
513            GroupDiscoverability::parse(" public ").unwrap(),
514            GroupDiscoverability::Public
515        );
516        assert_eq!(
517            GroupAdmissionMode::parse("approval-required").unwrap(),
518            GroupAdmissionMode::ApprovalRequired
519        );
520        assert_eq!(GroupAdmissionMode::ApprovalRequired.as_str(), "approval");
521        assert_eq!(
522            GroupMessageSecurityProfile::parse("group-e2ee").unwrap(),
523            GroupMessageSecurityProfile::GroupE2ee
524        );
525        assert_eq!(
526            GroupMemberRole::parse(" moderator ").unwrap(),
527            GroupMemberRole::Custom("moderator".to_string())
528        );
529    }
530
531    #[test]
532    fn group_member_limit_rejects_empty_zero_and_non_numeric_values() {
533        assert_eq!(GroupMemberLimit::parse(" 25 ").unwrap().as_u32(), 25);
534        assert!(GroupMemberLimit::parse("").is_err());
535        assert!(GroupMemberLimit::parse("0").is_err());
536        assert!(GroupMemberLimit::parse("many").is_err());
537    }
538
539    #[test]
540    fn group_member_limit_keeps_json_number_and_string_compatibility() {
541        let from_number: GroupMemberLimit = serde_json::from_value(serde_json::json!(12)).unwrap();
542        let from_string: GroupMemberLimit =
543            serde_json::from_value(serde_json::json!("12")).unwrap();
544
545        assert_eq!(from_number, from_string);
546        assert_eq!(
547            serde_json::to_value(from_number).unwrap(),
548            serde_json::json!(12)
549        );
550    }
551}
552
553impl Serialize for GroupDiscoverability {
554    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
555    where
556        S: Serializer,
557    {
558        serializer.serialize_str(self.as_str())
559    }
560}
561
562impl<'de> Deserialize<'de> for GroupDiscoverability {
563    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
564    where
565        D: Deserializer<'de>,
566    {
567        Self::parse(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
568    }
569}
570
571impl Serialize for GroupAdmissionMode {
572    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
573    where
574        S: Serializer,
575    {
576        serializer.serialize_str(self.as_str())
577    }
578}
579
580impl<'de> Deserialize<'de> for GroupAdmissionMode {
581    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
582    where
583        D: Deserializer<'de>,
584    {
585        Self::parse(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
586    }
587}
588
589impl Serialize for GroupMessageSecurityProfile {
590    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
591    where
592        S: Serializer,
593    {
594        serializer.serialize_str(self.as_str())
595    }
596}
597
598impl<'de> Deserialize<'de> for GroupMessageSecurityProfile {
599    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
600    where
601        D: Deserializer<'de>,
602    {
603        Self::parse(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
604    }
605}
606
607impl Serialize for GroupMemberRole {
608    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
609    where
610        S: Serializer,
611    {
612        serializer.serialize_str(self.as_str())
613    }
614}
615
616impl<'de> Deserialize<'de> for GroupMemberRole {
617    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
618    where
619        D: Deserializer<'de>,
620    {
621        Self::parse(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
622    }
623}
624
625impl Serialize for GroupKeyPackagePurpose {
626    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
627    where
628        S: Serializer,
629    {
630        serializer.serialize_str(self.as_str())
631    }
632}
633
634impl<'de> Deserialize<'de> for GroupKeyPackagePurpose {
635    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
636    where
637        D: Deserializer<'de>,
638    {
639        Self::parse(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
640    }
641}
642
643impl Serialize for GroupMemberLimit {
644    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
645    where
646        S: Serializer,
647    {
648        serializer.serialize_u32(self.0)
649    }
650}
651
652impl<'de> Deserialize<'de> for GroupMemberLimit {
653    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
654    where
655        D: Deserializer<'de>,
656    {
657        struct GroupMemberLimitVisitor;
658
659        impl serde::de::Visitor<'_> for GroupMemberLimitVisitor {
660            type Value = GroupMemberLimit;
661
662            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
663                formatter.write_str("a positive integer or decimal string")
664            }
665
666            fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
667            where
668                E: serde::de::Error,
669            {
670                let value = u32::try_from(value).map_err(E::custom)?;
671                GroupMemberLimit::new(value).map_err(E::custom)
672            }
673
674            fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
675            where
676                E: serde::de::Error,
677            {
678                let value = u32::try_from(value).map_err(E::custom)?;
679                GroupMemberLimit::new(value).map_err(E::custom)
680            }
681
682            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
683            where
684                E: serde::de::Error,
685            {
686                GroupMemberLimit::parse(value.to_string()).map_err(E::custom)
687            }
688
689            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
690            where
691                E: serde::de::Error,
692            {
693                GroupMemberLimit::parse(value).map_err(E::custom)
694            }
695        }
696
697        deserializer.deserialize_any(GroupMemberLimitVisitor)
698    }
699}
700
701fn parse_group_token<T>(
702    input: impl Into<String>,
703    field: &'static str,
704    mapper: impl FnOnce(&str) -> T,
705) -> crate::ImResult<T> {
706    let input = input.into();
707    let trimmed = input.trim();
708    if trimmed.is_empty() {
709        return Err(crate::ImError::invalid_input(
710            Some(field.to_string()),
711            format!("{field} must not be empty"),
712        ));
713    }
714    Ok(mapper(trimmed))
715}
716
717fn parse_optional_group_token<T>(
718    input: impl AsRef<str>,
719    parser: impl FnOnce(String) -> crate::ImResult<T>,
720) -> crate::ImResult<Option<T>> {
721    let trimmed = input.as_ref().trim();
722    if trimmed.is_empty() {
723        return Ok(None);
724    }
725    parser(trimmed.to_string()).map(Some)
726}
727
728#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
729pub struct GroupUpdateProfileRequest {
730    pub group: crate::ids::GroupRef,
731    pub patch: GroupProfilePatch,
732}
733
734#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
735pub struct GroupUpdatePolicyRequest {
736    pub group: crate::ids::GroupRef,
737    pub patch: GroupPolicyPatch,
738}
739
740#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
741pub struct GroupUpdateRequest {
742    pub group: crate::ids::GroupRef,
743    pub profile_patch: GroupProfilePatch,
744    pub policy_patch: GroupPolicyPatch,
745}
746
747#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
748pub struct GroupUpdateResult {
749    pub deliveries: Vec<GroupReadResult>,
750    pub refreshed: Option<GroupReadResult>,
751    pub warnings: Vec<String>,
752}
753
754#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
755pub struct GroupListRequest {
756    pub limit: crate::ids::PageLimit,
757}
758
759#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
760pub struct GroupMembersRequest {
761    pub group: crate::ids::GroupRef,
762    pub limit: crate::ids::PageLimit,
763}
764
765#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
766pub struct GroupMessagesRequest {
767    pub group: crate::ids::GroupRef,
768    pub limit: crate::ids::PageLimit,
769    pub cursor: Option<crate::ids::Cursor>,
770}
771
772#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
773pub struct GroupSnapshot {
774    pub id: Option<String>,
775    pub did: crate::ids::GroupRef,
776    pub name: Option<String>,
777    pub display_name: Option<String>,
778    pub description: Option<String>,
779    pub avatar_uri: Option<String>,
780    pub my_role: Option<String>,
781    pub membership_status: Option<String>,
782    pub member_count: Option<u32>,
783    pub last_message_at: Option<String>,
784}
785
786#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
787pub struct GroupSummary {
788    pub id: Option<String>,
789    pub did: crate::ids::GroupRef,
790    pub name: Option<String>,
791    pub display_name: Option<String>,
792    pub avatar_uri: Option<String>,
793    pub my_role: Option<String>,
794    pub membership_status: Option<String>,
795    pub member_count: Option<u32>,
796    pub last_message_at: Option<String>,
797}
798
799#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
800pub struct GroupMember {
801    pub did: Option<crate::ids::Did>,
802    pub handle: Option<crate::ids::Handle>,
803    pub role: Option<String>,
804    pub status: Option<String>,
805    pub joined_at: Option<String>,
806    pub subject_type: Option<String>,
807}
808
809fn group_snapshot_from_response(raw: &Value) -> Option<GroupSnapshot> {
810    if let Some(group) = raw.get("group") {
811        return group_snapshot_from_value(group);
812    }
813    if let Some(group) = raw.get("group_snapshot") {
814        return group_snapshot_from_value(group);
815    }
816    if raw_is_group_snapshot(raw) {
817        return group_snapshot_from_value(raw);
818    }
819    None
820}
821
822fn raw_is_group_snapshot(raw: &Value) -> bool {
823    let Some(object) = raw.as_object() else {
824        return false;
825    };
826    if object.contains_key("accepted")
827        || object.contains_key("final_acceptance")
828        || object.contains_key("operation_id")
829        || object.contains_key("group_receipt")
830        || object.contains_key("member_did")
831        || object.contains_key("leaver_did")
832    {
833        return false;
834    }
835    object.contains_key("group_profile")
836        || object.contains_key("name")
837        || object.contains_key("display_name")
838        || object.contains_key("description")
839        || object.contains_key("member_role")
840        || object.contains_key("my_role")
841        || object.contains_key("actor_membership_role")
842        || object.contains_key("member_status")
843        || object.contains_key("actor_membership_status")
844}
845
846fn group_snapshot_from_value(value: &Value) -> Option<GroupSnapshot> {
847    let object = value.as_object()?;
848    let did = group_ref_from_object(object)?;
849    let display_name = optional_string(object.get("display_name"))
850        .or_else(|| nested_string(object.get("group_profile"), "display_name"))
851        .or_else(|| optional_string(object.get("name")));
852    Some(GroupSnapshot {
853        id: optional_string(object.get("id")).or_else(|| optional_string(object.get("group_id"))),
854        did,
855        name: display_name.clone(),
856        display_name,
857        description: optional_string(object.get("description"))
858            .or_else(|| nested_string(object.get("group_profile"), "description")),
859        avatar_uri: optional_string(object.get("avatar_uri"))
860            .or_else(|| nested_string(object.get("group_profile"), "avatar_uri"))
861            .or_else(|| optional_string(object.get("avatar_url")))
862            .or_else(|| optional_string(object.get("avatar"))),
863        my_role: optional_string(object.get("my_role"))
864            .or_else(|| optional_string(object.get("member_role")))
865            .or_else(|| optional_string(object.get("actor_membership_role"))),
866        membership_status: optional_string(object.get("membership_status"))
867            .or_else(|| optional_string(object.get("member_status")))
868            .or_else(|| optional_string(object.get("actor_membership_status")))
869            .or_else(|| optional_string(object.get("status"))),
870        member_count: u32_value(object.get("member_count")),
871        last_message_at: optional_string(object.get("last_message_at")),
872    })
873}
874
875fn group_summary_from_value(value: Value) -> Option<GroupSummary> {
876    let object = value.as_object()?;
877    let did = group_ref_from_object(object)?;
878    let display_name = optional_string(object.get("display_name"))
879        .or_else(|| nested_string(object.get("group_profile"), "display_name"))
880        .or_else(|| optional_string(object.get("name")));
881    Some(GroupSummary {
882        id: optional_string(object.get("id")).or_else(|| optional_string(object.get("group_id"))),
883        did,
884        name: display_name.clone(),
885        display_name,
886        avatar_uri: optional_string(object.get("avatar_uri"))
887            .or_else(|| nested_string(object.get("group_profile"), "avatar_uri"))
888            .or_else(|| optional_string(object.get("avatar_url")))
889            .or_else(|| optional_string(object.get("avatar"))),
890        my_role: optional_string(object.get("my_role"))
891            .or_else(|| optional_string(object.get("member_role")))
892            .or_else(|| optional_string(object.get("actor_membership_role"))),
893        membership_status: optional_string(object.get("membership_status"))
894            .or_else(|| optional_string(object.get("member_status")))
895            .or_else(|| optional_string(object.get("status"))),
896        member_count: u32_value(object.get("member_count")),
897        last_message_at: optional_string(object.get("last_message_at")),
898    })
899}
900
901fn group_member_from_value(value: Value) -> Option<GroupMember> {
902    let object = value.as_object()?;
903    let explicit_member_did =
904        optional_string(object.get("did")).or_else(|| optional_string(object.get("member_did")));
905    let did_source_is_agent = explicit_member_did.is_none();
906    let did = explicit_member_did
907        .or_else(|| optional_string(object.get("agent_did")))
908        .and_then(|value| crate::ids::Did::parse(value).ok());
909    let subject_type = optional_string(object.get("subject_type"))
910        .or_else(|| optional_string(object.get("subjectType")))
911        .or_else(|| optional_string(object.get("member_subject_type")))
912        .or_else(|| {
913            did_source_is_agent
914                .then(|| optional_string(object.get("agent_subject_type")))
915                .flatten()
916        })
917        .or_else(|| inferred_subject_type(did.as_ref(), object));
918    let use_agent_handle =
919        did_source_is_agent || subject_type.as_deref().is_some_and(is_agent_subject_type);
920    Some(GroupMember {
921        did,
922        handle: optional_string(object.get("handle"))
923            .or_else(|| optional_string(object.get("member_handle")))
924            .or_else(|| {
925                use_agent_handle
926                    .then(|| optional_string(object.get("agent_handle")))
927                    .flatten()
928            })
929            .and_then(|value| crate::ids::Handle::parse(value, "").ok()),
930        role: optional_string(object.get("role")),
931        status: optional_string(object.get("status")),
932        joined_at: optional_string(object.get("joined_at")),
933        subject_type,
934    })
935}
936
937fn inferred_subject_type(
938    did: Option<&crate::ids::Did>,
939    object: &serde_json::Map<String, Value>,
940) -> Option<String> {
941    if let Some(did) = did {
942        let did = did.as_str().trim();
943        if did.starts_with("did:agent:") || did.contains(":agent:") {
944            return Some("agent".to_string());
945        }
946        if did.starts_with("did:") {
947            return Some("human".to_string());
948        }
949    }
950    if optional_string(object.get("agent_did")).is_some()
951        || optional_string(object.get("agent_handle")).is_some()
952    {
953        return Some("agent".to_string());
954    }
955    None
956}
957
958fn is_agent_subject_type(value: &str) -> bool {
959    matches!(
960        value.trim().to_ascii_lowercase().as_str(),
961        "agent" | "runtime_agent" | "bot"
962    )
963}
964
965fn group_message_from_value(value: Value) -> Option<crate::messages::Message> {
966    let object = value.as_object()?;
967    let id = optional_string(object.get("id"))
968        .or_else(|| optional_string(object.get("message_id")))
969        .or_else(|| optional_string(object.get("msg_id")))?;
970    let group_did = optional_string(object.get("group_did"))
971        .or_else(|| optional_string(object.get("group")))
972        .unwrap_or_else(|| "group:unknown".to_string());
973    let sender = optional_string(object.get("sender_did"))
974        .unwrap_or_else(|| "did:unknown:sender".to_string());
975    let content_type = optional_string(object.get("content_type"));
976    let secure = object
977        .get("secure")
978        .and_then(Value::as_bool)
979        .unwrap_or(false);
980    let is_attachment_manifest = content_type.as_deref()
981        == Some(crate::attachments::manifest::attachment_manifest_content_type());
982    let body = if content_type.as_deref() == Some("application/json") || is_attachment_manifest {
983        object
984            .get("payload")
985            .or_else(|| object.get("content"))
986            .or_else(|| object.get("body").and_then(|body| body.get("payload")))
987            .cloned()
988            .filter(Value::is_object)
989            .map(|payload| crate::messages::MessageBodyView::Payload { payload })
990            .unwrap_or(crate::messages::MessageBodyView::Unsupported {
991                content_type: content_type.clone(),
992            })
993    } else if let Some(text) = optional_string(object.get("text"))
994        .or_else(|| optional_string(object.get("content")))
995        .or_else(|| nested_string(object.get("body"), "text"))
996    {
997        crate::messages::MessageBodyView::Text {
998            text,
999            kind: message_kind(content_type.as_deref()),
1000        }
1001    } else {
1002        crate::messages::MessageBodyView::Unsupported {
1003            content_type: content_type.clone(),
1004        }
1005    };
1006    let group = crate::ids::GroupRef::parse(&group_did).ok()?;
1007    Some(crate::messages::Message {
1008        id: crate::ids::MessageId::parse(id).ok()?,
1009        thread: crate::messages::ThreadRef::Group(group.clone()),
1010        direction: crate::messages::MessageDirection::Unknown,
1011        sender: crate::ids::PeerRef::parse(sender, "").ok()?,
1012        receiver: None,
1013        group: Some(group),
1014        body,
1015        sent_at: optional_string(object.get("sent_at"))
1016            .or_else(|| optional_string(object.get("created_at"))),
1017        received_at: optional_string(object.get("received_at")),
1018        metadata: crate::messages::MessageMetadata {
1019            operation_id: optional_string(object.get("operation_id")),
1020            delivery_state: optional_string(object.get("delivery_state")),
1021            server_sequence: i64_value(object.get("server_seq"))
1022                .or_else(|| i64_value(object.get("sequence")))
1023                .or_else(|| i64_value(object.get("group_event_seq"))),
1024            content_type,
1025            attributes: group_message_attributes(object, secure),
1026            ..crate::messages::MessageMetadata::default()
1027        },
1028    })
1029}
1030
1031fn group_message_attributes(
1032    object: &serde_json::Map<String, Value>,
1033    secure: bool,
1034) -> Vec<crate::messages::MessageMetadataAttribute> {
1035    let mut attributes = Vec::new();
1036    if secure {
1037        attributes.push(crate::messages::MessageMetadataAttribute {
1038            key: "security".to_owned(),
1039            value: "group-e2ee".to_owned(),
1040        });
1041        attributes.push(crate::messages::MessageMetadataAttribute {
1042            key: "message_security_profile".to_owned(),
1043            value: "group-e2ee".to_owned(),
1044        });
1045    }
1046    for key in [
1047        "decryption_state",
1048        "secure_wire_content_type",
1049        "type",
1050        "message_security_profile",
1051        "security_profile",
1052    ] {
1053        if let Some(value) = optional_string(object.get(key)) {
1054            if attributes
1055                .iter()
1056                .any(|attribute| attribute.key == key && attribute.value == value)
1057            {
1058                continue;
1059            }
1060            attributes.push(crate::messages::MessageMetadataAttribute {
1061                key: key.to_owned(),
1062                value,
1063            });
1064        }
1065    }
1066    if let Some(content_type) = optional_string(object.get("content_type")) {
1067        attributes.push(crate::messages::MessageMetadataAttribute {
1068            key: "content_type".to_owned(),
1069            value: content_type,
1070        });
1071    }
1072    attributes
1073}
1074
1075fn group_ref_from_object(object: &serde_json::Map<String, Value>) -> Option<crate::ids::GroupRef> {
1076    optional_string(object.get("group_did"))
1077        .or_else(|| optional_string(object.get("did")))
1078        .or_else(|| optional_string(object.get("id")))
1079        .and_then(|value| crate::ids::GroupRef::parse(value).ok())
1080}
1081
1082fn values_from_array(value: Option<&Value>) -> Vec<Value> {
1083    value
1084        .and_then(Value::as_array)
1085        .map(|items| items.to_vec())
1086        .unwrap_or_default()
1087}
1088
1089fn optional_string(value: Option<&Value>) -> Option<String> {
1090    value
1091        .and_then(Value::as_str)
1092        .map(str::trim)
1093        .filter(|value| !value.is_empty())
1094        .map(str::to_string)
1095}
1096
1097fn nested_string(value: Option<&Value>, key: &str) -> Option<String> {
1098    value
1099        .and_then(Value::as_object)
1100        .and_then(|object| optional_string(object.get(key)))
1101}
1102
1103fn cursor_from_value(value: Option<&Value>) -> Option<crate::ids::Cursor> {
1104    optional_string(value).and_then(|value| crate::ids::Cursor::parse(value).ok())
1105}
1106
1107fn u32_value(value: Option<&Value>) -> Option<u32> {
1108    value
1109        .and_then(Value::as_u64)
1110        .and_then(|value| u32::try_from(value).ok())
1111}
1112
1113fn i64_value(value: Option<&Value>) -> Option<i64> {
1114    value.and_then(Value::as_i64)
1115}
1116
1117fn bool_value(value: Option<&Value>) -> bool {
1118    value.and_then(Value::as_bool).unwrap_or(false)
1119}
1120
1121fn merge_raw_warnings(raw_warnings: Option<&Value>, mut warnings: Vec<String>) -> Vec<String> {
1122    let Some(items) = raw_warnings.and_then(Value::as_array) else {
1123        return warnings;
1124    };
1125    for item in items {
1126        if let Some(warning) = item
1127            .as_str()
1128            .map(str::trim)
1129            .filter(|value| !value.is_empty())
1130        {
1131            let warning = warning.to_owned();
1132            if !warnings.iter().any(|known| known == &warning) {
1133                warnings.push(warning);
1134            }
1135        }
1136    }
1137    warnings
1138}
1139
1140fn message_kind(content_type: Option<&str>) -> crate::messages::MessageKind {
1141    match content_type.map(str::trim) {
1142        Some("text/markdown" | "markdown" | "text/x-markdown") => {
1143            crate::messages::MessageKind::Markdown
1144        }
1145        _ => crate::messages::MessageKind::Text,
1146    }
1147}
1148
1149#[cfg(test)]
1150mod tests {
1151    use super::*;
1152    use serde_json::json;
1153
1154    #[test]
1155    fn group_create_request_new_sets_only_required_name() {
1156        let request = GroupCreateRequest::new("Demo Group");
1157
1158        assert_eq!(request.name, "Demo Group");
1159        assert_eq!(request.description, None);
1160        assert_eq!(request.avatar_uri, None);
1161        assert_eq!(request.security, GroupSecurityRequirement::default());
1162        assert!(!request.e2ee);
1163        assert_eq!(request.discoverability, None);
1164        assert_eq!(request.admission_mode, None);
1165        assert_eq!(request.message_security_profile, None);
1166    }
1167
1168    #[test]
1169    fn group_result_projects_domain_fields_and_keeps_raw_response() {
1170        let result = GroupReadResult::from_raw_response(
1171            json!({
1172                "group_did": "did:example:group",
1173                "name": "Demo",
1174                "membership_status": "active",
1175                "member_count": 2,
1176                "groups": [{
1177                    "group_did": "did:example:group",
1178                    "name": "Demo",
1179                    "membership_status": "active"
1180                }],
1181                "members": [{
1182                    "member_did": "did:example:bob",
1183                    "handle": "bob.example",
1184                    "role": "member",
1185                    "status": "active"
1186                }],
1187                "messages": [{
1188                    "id": "msg-1",
1189                    "group_did": "did:example:group",
1190                    "sender_did": "did:example:bob",
1191                    "text": "hello",
1192                    "sent_at": "2026-01-01T00:00:00Z"
1193                }],
1194                "total": 1,
1195                "has_more": false,
1196                "source": "remote_http"
1197            }),
1198            vec!["normalized".to_string()],
1199        );
1200
1201        let group = result.group.as_ref().expect("group snapshot");
1202        assert_eq!(group.did.as_str(), "did:example:group");
1203        assert_eq!(group.name.as_deref(), Some("Demo"));
1204        assert_eq!(group.member_count, Some(2));
1205        assert_eq!(result.groups[0].did.as_str(), "did:example:group");
1206        assert_eq!(
1207            result.members[0].did.as_ref().map(crate::ids::Did::as_str),
1208            Some("did:example:bob")
1209        );
1210        assert_eq!(result.messages.items[0].id.as_str(), "msg-1");
1211        assert_eq!(result.total, Some(1));
1212        assert_eq!(result.source.as_deref(), Some("remote_http"));
1213        assert_eq!(result.warnings, vec!["normalized"]);
1214        assert_eq!(
1215            result.raw_response().and_then(|raw| raw.get("group_did")),
1216            Some(&json!("did:example:group"))
1217        );
1218    }
1219
1220    #[test]
1221    fn group_member_subject_type_ignores_empty_agent_fields() {
1222        let result = GroupReadResult::from_raw_response(
1223            json!({
1224                "group_did": "did:example:group",
1225                "name": "Demo",
1226                "members": [{
1227                    "member_did": "did:wba:awiki.info:user:zhuocheng:e1",
1228                    "handle": "zhuocheng",
1229                    "agent_handle": "",
1230                    "role": "member",
1231                    "status": "active"
1232                }, {
1233                    "agent_did": "did:wba:awiki.info:agent:runtime:hermes:e1",
1234                    "agent_handle": "hermes.awiki.info",
1235                    "role": "member",
1236                    "status": "active"
1237                }]
1238            }),
1239            Vec::new(),
1240        );
1241
1242        assert_eq!(result.members.len(), 2);
1243        assert_eq!(result.members[0].subject_type.as_deref(), Some("human"));
1244        assert_eq!(result.members[1].subject_type.as_deref(), Some("agent"));
1245    }
1246
1247    #[test]
1248    fn group_member_subject_type_prefers_member_did_over_auxiliary_agent_fields() {
1249        let result = GroupReadResult::from_raw_response(
1250            json!({
1251                "group_did": "did:example:group",
1252                "members": [{
1253                    "member_did": "did:wba:anpclaw.com:zhuocheng:e1_human",
1254                    "agent_did": "did:wba:anpclaw.com:agent:runtime:helper:e1_agent",
1255                    "handle": "zhuocheng",
1256                    "agent_handle": "helper.anpclaw.com",
1257                    "agent_subject_type": "agent",
1258                    "role": "member",
1259                    "status": "active"
1260                }]
1261            }),
1262            Vec::new(),
1263        );
1264
1265        assert_eq!(result.members.len(), 1);
1266        assert_eq!(
1267            result.members[0].did.as_ref().map(|did| did.as_str()),
1268            Some("did:wba:anpclaw.com:zhuocheng:e1_human")
1269        );
1270        assert_eq!(
1271            result.members[0]
1272                .handle
1273                .as_ref()
1274                .map(|handle| handle.as_str()),
1275            Some("zhuocheng")
1276        );
1277        assert_eq!(result.members[0].subject_type.as_deref(), Some("human"));
1278    }
1279
1280    #[test]
1281    fn group_result_does_not_treat_member_mutation_response_as_viewer_snapshot() {
1282        let result = GroupReadResult::from_raw_response(
1283            json!({
1284                "accepted": true,
1285                "final_acceptance": true,
1286                "group_did": "did:example:group",
1287                "group_state_version": "12",
1288                "group_event_seq": "7",
1289                "operation_id": "op-remove-bob",
1290                "member_did": "did:example:bob",
1291                "membership_status": "removed"
1292            }),
1293            Vec::new(),
1294        );
1295
1296        assert!(
1297            result.group.is_none(),
1298            "target member status in a group.remove response must not be read as the viewer membership status"
1299        );
1300        assert_eq!(
1301            result
1302                .raw_response()
1303                .and_then(|raw| raw.get("membership_status")),
1304            Some(&json!("removed"))
1305        );
1306    }
1307
1308    #[test]
1309    fn group_result_still_projects_explicit_group_snapshot_wrapper() {
1310        let result = GroupReadResult::from_raw_response(
1311            json!({
1312                "group": {
1313                    "group_did": "did:example:group",
1314                    "name": "Demo",
1315                    "member_role": "owner",
1316                    "membership_status": "active",
1317                    "member_count": 2
1318                }
1319            }),
1320            Vec::new(),
1321        );
1322
1323        let group = result.group.as_ref().expect("group snapshot");
1324        assert_eq!(group.did.as_str(), "did:example:group");
1325        assert_eq!(group.my_role.as_deref(), Some("owner"));
1326        assert_eq!(group.membership_status.as_deref(), Some("active"));
1327    }
1328
1329    #[test]
1330    fn group_result_still_projects_local_group_snapshot_wrapper() {
1331        let result = GroupReadResult::from_raw_response(
1332            json!({
1333                "group_snapshot": {
1334                    "group_did": "did:example:group",
1335                    "name": "Demo",
1336                    "member_role": "admin",
1337                    "member_status": "active",
1338                    "member_count": 3
1339                }
1340            }),
1341            Vec::new(),
1342        );
1343
1344        let group = result.group.as_ref().expect("group snapshot");
1345        assert_eq!(group.did.as_str(), "did:example:group");
1346        assert_eq!(group.my_role.as_deref(), Some("admin"));
1347        assert_eq!(group.membership_status.as_deref(), Some("active"));
1348    }
1349
1350    #[test]
1351    fn group_result_merges_raw_warnings() {
1352        let result = GroupReadResult::from_raw_response(
1353            json!({
1354                "messages": [],
1355                "warnings": [
1356                    "Failed to decrypt group E2EE message did:example:group:3: aad_mismatch",
1357                    "",
1358                    42
1359                ],
1360                "has_more": false
1361            }),
1362            vec![
1363                "existing warning".to_owned(),
1364                "Failed to decrypt group E2EE message did:example:group:3: aad_mismatch".to_owned(),
1365            ],
1366        );
1367
1368        assert_eq!(
1369            result.warnings,
1370            vec![
1371                "existing warning",
1372                "Failed to decrypt group E2EE message did:example:group:3: aad_mismatch",
1373            ]
1374        );
1375    }
1376
1377    #[test]
1378    fn group_result_projects_secure_attachment_manifest_payload() {
1379        let result = GroupReadResult::from_raw_response(
1380            json!({
1381                "messages": [{
1382                    "id": "msg-secure-attachment",
1383                    "group_did": "did:example:group",
1384                    "sender_did": "did:example:alice",
1385                    "type": "attachment_manifest",
1386                    "content_type": crate::attachments::manifest::attachment_manifest_content_type(),
1387                    "secure": true,
1388                    "content": {
1389                        "attachments": [{
1390                            "attachment_id": "att-group-secure",
1391                            "size": "48",
1392                            "digest": {
1393                                "alg": "sha-256",
1394                                "value_b64u": "digest"
1395                            },
1396                            "mime_type": "text/plain",
1397                            "encryption_info": {
1398                                "mode": "object-e2ee",
1399                                "alg": "chacha20-poly1305",
1400                                "plaintext_size": "31",
1401                                "object_uri": "https://objects.example/secure"
1402                            }
1403                        }],
1404                        "caption": "secure attachment",
1405                        "primary_attachment_id": "att-group-secure"
1406                    },
1407                    "sent_at": "2026-01-01T00:00:00Z"
1408                }],
1409                "has_more": false
1410            }),
1411            Vec::new(),
1412        );
1413
1414        let message = &result.messages.items[0];
1415        assert!(matches!(
1416            &message.body,
1417            crate::messages::MessageBodyView::Payload { payload }
1418                if payload["attachments"][0]["attachment_id"] == "att-group-secure"
1419                    && payload["attachments"][0]["encryption_info"]["mode"] == "object-e2ee"
1420                    && payload["attachments"][0]["encryption_info"].get("object_key_b64u").is_none()
1421                    && payload["attachments"][0]["encryption_info"].get("nonce_b64u").is_none()
1422        ));
1423        assert!(message
1424            .metadata
1425            .attributes
1426            .iter()
1427            .any(|attribute| attribute.key == "security" && attribute.value == "group-e2ee"));
1428        assert!(message.metadata.attributes.iter().any(|attribute| {
1429            attribute.key == "message_security_profile" && attribute.value == "group-e2ee"
1430        }));
1431        assert!(message
1432            .metadata
1433            .attributes
1434            .iter()
1435            .any(|attribute| attribute.key == "type" && attribute.value == "attachment_manifest"));
1436        assert!(message.metadata.attributes.iter().any(|attribute| {
1437            attribute.key == "content_type"
1438                && attribute.value
1439                    == crate::attachments::manifest::attachment_manifest_content_type()
1440        }));
1441    }
1442}