Skip to main content

xmtp_mls_common/
group_metadata.rs

1use std::fmt::Display;
2
3use openmls::{extensions::Extensions, group::GroupContext};
4use prost::Message;
5use serde::Serialize;
6use thiserror::Error;
7use xmtp_common::ErrorCode;
8
9use xmtp_id::InboxId;
10use xmtp_proto::xmtp::mls::message_contents::{
11    ConversationType as ConversationTypeProto, DmMembers as DmMembersProto,
12    GroupMetadataV1 as GroupMetadataProto, Inbox as InboxProto, OneshotMessage,
13};
14
15use xmtp_proto::types::ConversationType;
16
17#[derive(Debug, Error, ErrorCode)]
18pub enum GroupMetadataError {
19    /// Serialization error.
20    ///
21    /// Failed to encode metadata protobuf. Not retryable.
22    #[error("serialization: {0}")]
23    Serialization(#[from] prost::EncodeError),
24    /// Deserialization error.
25    ///
26    /// Failed to decode metadata protobuf. Not retryable.
27    #[error("deserialization: {0}")]
28    Deserialization(#[from] prost::DecodeError),
29    /// Invalid conversation type.
30    ///
31    /// Protobuf conversation type not recognized. Not retryable.
32    #[error("invalid conversation type")]
33    InvalidConversationType,
34    /// Missing extension.
35    ///
36    /// Immutable metadata MLS extension not found. Not retryable.
37    #[error("missing extension")]
38    MissingExtension,
39    /// Invalid DM members.
40    ///
41    /// DM member data is invalid. Not retryable.
42    #[error("invalid dm members")]
43    InvalidDmMembers,
44    /// Missing DM member.
45    ///
46    /// A DM member field is not set. Not retryable.
47    #[error("missing a dm member")]
48    MissingDmMember,
49    #[error(transparent)]
50    #[error_code(inherit)]
51    Conversion(#[from] xmtp_proto::ConversionError),
52}
53
54/// `GroupMetadata` is immutable and created at the time of group creation.
55#[derive(Debug, Clone, PartialEq)]
56pub struct GroupMetadata {
57    pub conversation_type: ConversationType,
58    // TODO: Remove this once transition is completed
59    pub creator_inbox_id: String,
60    pub dm_members: Option<DmMembers<InboxId>>,
61    pub oneshot_message: Option<OneshotMessage>,
62}
63
64impl GroupMetadata {
65    pub fn new(
66        conversation_type: ConversationType,
67        creator_inbox_id: String,
68        dm_members: Option<DmMembers<InboxId>>,
69        oneshot_message: Option<OneshotMessage>,
70    ) -> Self {
71        Self {
72            conversation_type,
73            creator_inbox_id,
74            dm_members,
75            oneshot_message,
76        }
77    }
78}
79
80impl TryFrom<GroupMetadata> for Vec<u8> {
81    type Error = GroupMetadataError;
82
83    fn try_from(value: GroupMetadata) -> Result<Self, Self::Error> {
84        let conversation_type: ConversationTypeProto = value.conversation_type.into();
85        let proto_val = GroupMetadataProto {
86            conversation_type: conversation_type as i32,
87            creator_inbox_id: value.creator_inbox_id.clone(),
88            creator_account_address: "".to_string(), // TODO: remove from proto
89            dm_members: value.dm_members.clone().map(|dm| dm.into()),
90            oneshot_message: value.oneshot_message,
91        };
92        let mut buf: Vec<u8> = Vec::new();
93        proto_val.encode(&mut buf)?;
94
95        Ok(buf)
96    }
97}
98
99impl TryFrom<&Vec<u8>> for GroupMetadata {
100    type Error = GroupMetadataError;
101
102    fn try_from(value: &Vec<u8>) -> Result<Self, Self::Error> {
103        let proto_val = GroupMetadataProto::decode(value.as_slice())?;
104        proto_val.try_into()
105    }
106}
107
108impl TryFrom<GroupMetadataProto> for GroupMetadata {
109    type Error = GroupMetadataError;
110
111    fn try_from(value: GroupMetadataProto) -> Result<Self, Self::Error> {
112        let dm_members = value.dm_members.map(DmMembers::try_from).transpose()?;
113        Ok(Self::new(
114            value.conversation_type.try_into()?,
115            value.creator_inbox_id,
116            dm_members,
117            value.oneshot_message,
118        ))
119    }
120}
121
122impl TryFrom<&Extensions<GroupContext>> for GroupMetadata {
123    type Error = GroupMetadataError;
124
125    fn try_from(extensions: &Extensions<GroupContext>) -> Result<Self, Self::Error> {
126        let data = extensions
127            .immutable_metadata()
128            .ok_or(GroupMetadataError::MissingExtension)?;
129        data.metadata().try_into()
130    }
131}
132
133#[derive(Debug, Clone, PartialEq, Serialize)]
134pub struct DmMembers<Id: AsRef<str>> {
135    pub member_one_inbox_id: Id,
136    pub member_two_inbox_id: Id,
137}
138
139impl<'a> DmMembers<String> {
140    pub fn as_ref(&'a self) -> DmMembers<&'a str> {
141        DmMembers {
142            member_one_inbox_id: &*self.member_one_inbox_id,
143            member_two_inbox_id: &*self.member_two_inbox_id,
144        }
145    }
146}
147
148impl<Id> From<DmMembers<Id>> for DmMembersProto
149where
150    Id: AsRef<str>,
151{
152    fn from(value: DmMembers<Id>) -> Self {
153        DmMembersProto {
154            dm_member_one: Some(InboxProto {
155                inbox_id: value.member_one_inbox_id.as_ref().to_string(),
156            }),
157            dm_member_two: Some(InboxProto {
158                inbox_id: value.member_two_inbox_id.as_ref().to_string(),
159            }),
160        }
161    }
162}
163
164impl<Id> From<&DmMembers<Id>> for String
165where
166    Id: AsRef<str>,
167{
168    fn from(members: &DmMembers<Id>) -> Self {
169        members.to_string()
170    }
171}
172
173impl<Id> From<DmMembers<Id>> for String
174where
175    Id: AsRef<str>,
176{
177    fn from(members: DmMembers<Id>) -> Self {
178        members.to_string()
179    }
180}
181
182impl<Id> Display for DmMembers<Id>
183where
184    Id: AsRef<str>,
185{
186    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187        let mut inbox_ids = [
188            self.member_one_inbox_id.as_ref(),
189            self.member_two_inbox_id.as_ref(),
190        ]
191        .into_iter()
192        .map(str::to_lowercase)
193        .collect::<Vec<_>>();
194        inbox_ids.sort();
195
196        write!(f, "dm:{}", inbox_ids.join(":"))
197    }
198}
199
200impl TryFrom<DmMembersProto> for DmMembers<InboxId> {
201    type Error = GroupMetadataError;
202
203    fn try_from(value: DmMembersProto) -> Result<Self, Self::Error> {
204        Ok(Self {
205            member_one_inbox_id: value
206                .dm_member_one
207                .ok_or(GroupMetadataError::MissingDmMember)?
208                .inbox_id,
209            member_two_inbox_id: value
210                .dm_member_two
211                .ok_or(GroupMetadataError::MissingDmMember)?
212                .inbox_id,
213        })
214    }
215}
216
217/// Extract `GroupMetadata` from a group context.
218///
219/// **Capability-aware.** On migrated groups (post-bootstrap, where the
220/// AppData dictionary contains the canonical `COMPONENT_REGISTRY` entry)
221/// the metadata is reconstructed from the dict's `CONVERSATION_TYPE`,
222/// `CREATOR_INBOX_ID`, `DM_MEMBERS`, and `ONESHOT_MESSAGE` components.
223/// On unmigrated groups it is read from the legacy `ImmutableMetadata`
224/// MLS extension. Callers don't need to know which path applies.
225pub fn extract_group_metadata(
226    extensions: &Extensions<GroupContext>,
227) -> Result<GroupMetadata, GroupMetadataError> {
228    if let Some(metadata) = read_group_metadata_from_dict(extensions)? {
229        return Ok(metadata);
230    }
231
232    let extension = extensions
233        .immutable_metadata()
234        .ok_or(GroupMetadataError::MissingExtension)?;
235
236    extension.metadata().try_into()
237}
238
239/// Read `GroupMetadata` from the AppData dictionary on a migrated group.
240///
241/// Returns `Ok(None)` for unmigrated groups (no `COMPONENT_REGISTRY`
242/// entry in the dict, or no AppData dictionary at all) so the caller
243/// can fall back to the legacy `ImmutableMetadata` extension. Returns
244/// `Err` only on a malformed dict entry on a group that *is* migrated.
245fn read_group_metadata_from_dict(
246    extensions: &Extensions<GroupContext>,
247) -> Result<Option<GroupMetadata>, GroupMetadataError> {
248    use crate::app_data::component_id::ComponentId;
249    use crate::inbox_id::InboxId as DictInboxId;
250    use crate::tls_set::TlsSet;
251    use tls_codec::Deserialize;
252
253    let Some(ext) = extensions.app_data_dictionary() else {
254        return Ok(None);
255    };
256    let dict = ext.dictionary();
257
258    // Use COMPONENT_REGISTRY presence as the post-bootstrap marker. A
259    // pre-bootstrap group should never carry a stray dict entry that
260    // shadows the legacy extension.
261    if !dict.contains(&ComponentId::COMPONENT_REGISTRY.as_u16()) {
262        return Ok(None);
263    }
264
265    // On a migrated group these two are required. Falling back to the
266    // legacy `ImmutableMetadata` extension would mean trusting a stale
267    // (or absent) value, so surface the malformed dict instead.
268    let Some(ct_bytes) = dict.get(&ComponentId::CONVERSATION_TYPE.as_u16()) else {
269        return Err(GroupMetadataError::Conversion(
270            xmtp_proto::ConversionError::Missing {
271                item: "CONVERSATION_TYPE",
272                r#type: "AppData dictionary entry",
273            },
274        ));
275    };
276    let Some(creator_bytes) = dict.get(&ComponentId::CREATOR_INBOX_ID.as_u16()) else {
277        return Err(GroupMetadataError::Conversion(
278            xmtp_proto::ConversionError::Missing {
279                item: "CREATOR_INBOX_ID",
280                r#type: "AppData dictionary entry",
281            },
282        ));
283    };
284
285    // CONVERSATION_TYPE: 4-byte big-endian i32 matching `ConversationTypeProto`.
286    let ct_arr: [u8; 4] = ct_bytes
287        .try_into()
288        .map_err(|_| GroupMetadataError::InvalidConversationType)?;
289    let conversation_type_i32 = i32::from_be_bytes(ct_arr);
290    let conversation_type: ConversationType = conversation_type_i32.try_into()?;
291
292    // CREATOR_INBOX_ID: versioned `InboxId` TLS form. Hex-encode for the
293    // legacy `String` slot the rest of the codebase consumes.
294    let creator_inbox_id = DictInboxId::tls_deserialize_exact(creator_bytes)
295        .map_err(|e| {
296            GroupMetadataError::Conversion(xmtp_proto::ConversionError::InvalidValue {
297                item: "CREATOR_INBOX_ID",
298                expected: "versioned InboxId TLS encoding",
299                got: format!("{e}"),
300            })
301        })?
302        .to_hex();
303
304    // DM_MEMBERS: `TlsSet<InboxId>` with exactly two elements, or absent.
305    let dm_members = match dict.get(&ComponentId::DM_MEMBERS.as_u16()) {
306        Some(b) => {
307            let set = TlsSet::<DictInboxId>::tls_deserialize_exact(b)
308                .map_err(|_| GroupMetadataError::InvalidDmMembers)?;
309            let ids: Vec<DictInboxId> = set.iter().copied().collect();
310            if ids.len() != 2 {
311                return Err(GroupMetadataError::InvalidDmMembers);
312            }
313            Some(DmMembers {
314                member_one_inbox_id: ids[0].to_hex(),
315                member_two_inbox_id: ids[1].to_hex(),
316            })
317        }
318        None => None,
319    };
320
321    // ONESHOT_MESSAGE: prost-encoded `OneshotMessage`.
322    let oneshot_message = match dict.get(&ComponentId::ONESHOT_MESSAGE.as_u16()) {
323        Some(b) => Some(OneshotMessage::decode(b)?),
324        None => None,
325    };
326
327    Ok(Some(GroupMetadata {
328        conversation_type,
329        creator_inbox_id,
330        dm_members,
331        oneshot_message,
332    }))
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[xmtp_common::test]
340    fn test_dm_members_sort() {
341        let members = DmMembers {
342            member_one_inbox_id: "thats_me".to_string(),
343            member_two_inbox_id: "some_wise_guy".to_string(),
344        };
345
346        let members2 = DmMembers {
347            member_one_inbox_id: "some_wise_guy".to_string(),
348            member_two_inbox_id: "thats_me".to_string(),
349        };
350
351        assert_eq!(members.to_string(), members2.to_string());
352    }
353}