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}