ruma_events/call/
member.rs

1//! Types for MatrixRTC state events ([MSC3401]).
2//!
3//! This implements a newer/updated version of MSC3401.
4//!
5//! [MSC3401]: https://github.com/matrix-org/matrix-spec-proposals/pull/3401
6
7mod focus;
8mod member_data;
9mod member_state_key;
10
11pub use focus::*;
12pub use member_data::*;
13pub use member_state_key::*;
14use ruma_common::{MilliSecondsSinceUnixEpoch, OwnedDeviceId};
15use ruma_macros::{EventContent, StringEnum};
16use serde::{Deserialize, Serialize};
17
18use crate::{
19    PossiblyRedactedStateEventContent, PrivOwnedStr, RedactContent, RedactedStateEventContent,
20    StateEventType,
21};
22
23/// The member state event for a MatrixRTC session.
24///
25/// This is the object containing all the data related to a Matrix users participation in a
26/// MatrixRTC session.
27///
28/// This is a unit struct with the enum [`CallMemberEventContent`] because a Ruma state event cannot
29/// be an enum and we need this to be an untagged enum for parsing purposes. (see
30/// [`CallMemberEventContent`])
31///
32/// This struct also exposes allows to call the methods from [`CallMemberEventContent`].
33#[derive(Clone, Debug, Serialize, Deserialize, EventContent, PartialEq)]
34#[ruma_event(type = "org.matrix.msc3401.call.member", kind = State, state_key_type = CallMemberStateKey, custom_redacted, custom_possibly_redacted)]
35#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
36#[serde(untagged)]
37pub enum CallMemberEventContent {
38    /// The legacy format for m.call.member events. (An array of memberships. The devices of one
39    /// user.)
40    LegacyContent(LegacyMembershipContent),
41    /// Normal membership events. One event per membership. Multiple state keys will
42    /// be used to describe multiple devices for one user.
43    SessionContent(SessionMembershipData),
44    /// An empty content means this user has been in a rtc session but is not anymore.
45    Empty(EmptyMembershipData),
46}
47
48impl CallMemberEventContent {
49    /// Creates a new [`CallMemberEventContent`] with [`LegacyMembershipData`].
50    pub fn new_legacy(memberships: Vec<LegacyMembershipData>) -> Self {
51        Self::LegacyContent(LegacyMembershipContent {
52            memberships, //: memberships.into_iter().map(MembershipData::Legacy).collect(),
53        })
54    }
55
56    /// Creates a new [`CallMemberEventContent`] with [`SessionMembershipData`].
57    pub fn new(
58        application: Application,
59        device_id: OwnedDeviceId,
60        focus_active: ActiveFocus,
61        foci_preferred: Vec<Focus>,
62        created_ts: Option<MilliSecondsSinceUnixEpoch>,
63    ) -> Self {
64        Self::SessionContent(SessionMembershipData {
65            application,
66            device_id,
67            focus_active,
68            foci_preferred,
69            created_ts,
70        })
71    }
72
73    /// Creates a new Empty [`CallMemberEventContent`] representing a left membership.
74    pub fn new_empty(leave_reason: Option<LeaveReason>) -> Self {
75        Self::Empty(EmptyMembershipData { leave_reason })
76    }
77
78    /// All non expired memberships in this member event.
79    ///
80    /// In most cases you want to use this method instead of the public memberships field.
81    /// The memberships field will also include expired events.
82    ///
83    /// This copies all the memberships and converts them
84    /// # Arguments
85    ///
86    /// * `origin_server_ts` - optionally the `origin_server_ts` can be passed as a fallback in the
87    ///   Membership does not contain [`LegacyMembershipData::created_ts`]. (`origin_server_ts` will
88    ///   be ignored if [`LegacyMembershipData::created_ts`] is `Some`)
89    pub fn active_memberships(
90        &self,
91        origin_server_ts: Option<MilliSecondsSinceUnixEpoch>,
92    ) -> Vec<MembershipData<'_>> {
93        match self {
94            CallMemberEventContent::LegacyContent(content) => {
95                content.active_memberships(origin_server_ts)
96            }
97            CallMemberEventContent::SessionContent(content) => {
98                [content].map(MembershipData::Session).to_vec()
99            }
100            CallMemberEventContent::Empty(_) => Vec::new(),
101        }
102    }
103
104    /// All the memberships for this event. Can only contain multiple elements in the case of legacy
105    /// `m.call.member` state events.
106    pub fn memberships(&self) -> Vec<MembershipData<'_>> {
107        match self {
108            CallMemberEventContent::LegacyContent(content) => {
109                content.memberships.iter().map(MembershipData::Legacy).collect()
110            }
111            CallMemberEventContent::SessionContent(content) => {
112                [content].map(MembershipData::Session).to_vec()
113            }
114            CallMemberEventContent::Empty(_) => Vec::new(),
115        }
116    }
117
118    /// Set the `created_ts` of each [`MembershipData::Legacy`] in this event.
119    ///
120    /// Each call member event contains the `origin_server_ts` and `content.create_ts`.
121    /// `content.create_ts` is undefined for the initial event of a session (because the
122    /// `origin_server_ts` is not known on the client).
123    /// In the rust sdk we want to copy over the `origin_server_ts` of the event into the content.
124    /// (This allows to use `MinimalStateEvents` and still be able to determine if a membership is
125    /// expired)
126    pub fn set_created_ts_if_none(&mut self, origin_server_ts: MilliSecondsSinceUnixEpoch) {
127        match self {
128            CallMemberEventContent::LegacyContent(content) => {
129                content.memberships.iter_mut().for_each(|m: &mut LegacyMembershipData| {
130                    m.created_ts.get_or_insert(origin_server_ts);
131                });
132            }
133            CallMemberEventContent::SessionContent(m) => {
134                m.created_ts.get_or_insert(origin_server_ts);
135            }
136            _ => (),
137        }
138    }
139}
140
141/// This describes the CallMember event if the user is not part of the current session.
142#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
143#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
144pub struct EmptyMembershipData {
145    /// An empty call member state event can optionally contain a leave reason.
146    /// If it is `None` the user has left the call ordinarily. (Intentional hangup)  
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub leave_reason: Option<LeaveReason>,
149}
150
151/// This is the optional value for an empty membership event content:
152/// [`CallMemberEventContent::Empty`].
153///
154/// It is used when the user disconnected and a Future ([MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140))
155/// was used to update the membership after the client was not reachable anymore.  
156#[derive(Clone, PartialEq, StringEnum)]
157#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
158#[ruma_enum(rename_all = "m.snake_case")]
159pub enum LeaveReason {
160    /// The user left the call by losing network connection or closing  
161    /// the client before it was able to send the leave event.
162    LostConnection,
163    #[doc(hidden)]
164    _Custom(PrivOwnedStr),
165}
166
167impl RedactContent for CallMemberEventContent {
168    type Redacted = RedactedCallMemberEventContent;
169
170    fn redact(self, _version: &ruma_common::RoomVersionId) -> Self::Redacted {
171        RedactedCallMemberEventContent {}
172    }
173}
174
175/// The PossiblyRedacted version of [`CallMemberEventContent`].
176///
177/// Since [`CallMemberEventContent`] has the [`CallMemberEventContent::Empty`] state it already is
178/// compatible with the redacted version of the state event content.
179pub type PossiblyRedactedCallMemberEventContent = CallMemberEventContent;
180
181impl PossiblyRedactedStateEventContent for PossiblyRedactedCallMemberEventContent {
182    type StateKey = CallMemberStateKey;
183}
184
185/// The Redacted version of [`CallMemberEventContent`].
186#[derive(Clone, Debug, Deserialize, Serialize)]
187#[allow(clippy::exhaustive_structs)]
188pub struct RedactedCallMemberEventContent {}
189
190impl ruma_events::content::EventContent for RedactedCallMemberEventContent {
191    type EventType = StateEventType;
192    fn event_type(&self) -> Self::EventType {
193        StateEventType::CallMember
194    }
195}
196
197impl RedactedStateEventContent for RedactedCallMemberEventContent {
198    type StateKey = CallMemberStateKey;
199}
200
201/// Legacy content with an array of memberships. See also: [`CallMemberEventContent`]
202#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
203#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
204pub struct LegacyMembershipContent {
205    /// A list of all the memberships that user currently has in this room.
206    ///
207    /// There can be multiple ones in case the user participates with multiple devices or there
208    /// are multiple RTC applications running.
209    ///
210    /// e.g. a call and a spacial experience.
211    ///
212    /// Important: This includes expired memberships.
213    /// To retrieve a list including only valid memberships,
214    /// see [`active_memberships`](CallMemberEventContent::active_memberships).
215    memberships: Vec<LegacyMembershipData>,
216}
217
218impl LegacyMembershipContent {
219    fn active_memberships(
220        &self,
221        origin_server_ts: Option<MilliSecondsSinceUnixEpoch>,
222    ) -> Vec<MembershipData<'_>> {
223        self.memberships
224            .iter()
225            .filter(|m| !m.is_expired(origin_server_ts))
226            .map(MembershipData::Legacy)
227            .collect()
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use std::time::Duration;
234
235    use assert_matches2::assert_matches;
236    use ruma_common::{
237        device_id, owned_device_id, user_id, MilliSecondsSinceUnixEpoch as TS, OwnedEventId,
238        OwnedRoomId, OwnedUserId,
239    };
240    use serde_json::{from_value as from_json_value, json, Value as JsonValue};
241
242    use super::{
243        focus::{ActiveFocus, ActiveLivekitFocus, Focus, LivekitFocus},
244        member_data::{
245            Application, CallApplicationContent, CallScope, LegacyMembershipData, MembershipData,
246        },
247        CallMemberEventContent,
248    };
249    use crate::{
250        call::member::{EmptyMembershipData, FocusSelection, SessionMembershipData},
251        AnyStateEvent, StateEvent,
252    };
253
254    fn create_call_member_legacy_event_content() -> CallMemberEventContent {
255        CallMemberEventContent::new_legacy(vec![LegacyMembershipData {
256            application: Application::Call(CallApplicationContent {
257                call_id: "123456".to_owned(),
258                scope: CallScope::Room,
259            }),
260            device_id: owned_device_id!("ABCDE"),
261            expires: Duration::from_secs(3600),
262            foci_active: vec![Focus::Livekit(LivekitFocus {
263                alias: "1".to_owned(),
264                service_url: "https://livekit.com".to_owned(),
265            })],
266            membership_id: "0".to_owned(),
267            created_ts: None,
268        }])
269    }
270
271    fn create_call_member_event_content() -> CallMemberEventContent {
272        CallMemberEventContent::new(
273            Application::Call(CallApplicationContent {
274                call_id: "123456".to_owned(),
275                scope: CallScope::Room,
276            }),
277            owned_device_id!("ABCDE"),
278            ActiveFocus::Livekit(ActiveLivekitFocus {
279                focus_selection: FocusSelection::OldestMembership,
280            }),
281            vec![Focus::Livekit(LivekitFocus {
282                alias: "1".to_owned(),
283                service_url: "https://livekit.com".to_owned(),
284            })],
285            None,
286        )
287    }
288
289    #[test]
290    fn serialize_call_member_event_content() {
291        let call_member_event = &json!({
292            "application": "m.call",
293            "call_id": "123456",
294            "scope": "m.room",
295            "device_id": "ABCDE",
296            "foci_preferred": [
297                {
298                    "livekit_alias": "1",
299                    "livekit_service_url": "https://livekit.com",
300                    "type": "livekit"
301                }
302            ],
303            "focus_active":{
304                "type":"livekit",
305                "focus_selection":"oldest_membership"
306            }
307        });
308        assert_eq!(
309            call_member_event,
310            &serde_json::to_value(create_call_member_event_content()).unwrap()
311        );
312
313        let empty_call_member_event = &json!({});
314        assert_eq!(
315            empty_call_member_event,
316            &serde_json::to_value(CallMemberEventContent::Empty(EmptyMembershipData {
317                leave_reason: None
318            }))
319            .unwrap()
320        );
321    }
322
323    #[test]
324    fn serialize_legacy_call_member_event_content() {
325        let call_member_event = &json!({
326            "memberships": [
327                {
328                    "application": "m.call",
329                    "call_id": "123456",
330                    "scope": "m.room",
331                    "device_id": "ABCDE",
332                    "expires": 3_600_000,
333                    "foci_active": [
334                        {
335                            "livekit_alias": "1",
336                            "livekit_service_url": "https://livekit.com",
337                            "type": "livekit"
338                        }
339                    ],
340                    "membershipID": "0"
341                }
342            ]
343        });
344
345        assert_eq!(
346            call_member_event,
347            &serde_json::to_value(create_call_member_legacy_event_content()).unwrap()
348        );
349    }
350    #[test]
351    fn deserialize_call_member_event_content() {
352        let call_member_ev = CallMemberEventContent::new(
353            Application::Call(CallApplicationContent {
354                call_id: "123456".to_owned(),
355                scope: CallScope::Room,
356            }),
357            owned_device_id!("THIS_DEVICE"),
358            ActiveFocus::Livekit(ActiveLivekitFocus {
359                focus_selection: FocusSelection::OldestMembership,
360            }),
361            vec![Focus::Livekit(LivekitFocus {
362                alias: "room1".to_owned(),
363                service_url: "https://livekit1.com".to_owned(),
364            })],
365            None,
366        );
367
368        let call_member_ev_json = json!({
369            "application": "m.call",
370            "call_id": "123456",
371            "scope": "m.room",
372            "device_id": "THIS_DEVICE",
373            "focus_active":{
374                "type": "livekit",
375                "focus_selection": "oldest_membership"
376            },
377            "foci_preferred": [
378                {
379                    "livekit_alias": "room1",
380                    "livekit_service_url": "https://livekit1.com",
381                    "type": "livekit"
382                }
383            ],
384        });
385
386        let ev_content: CallMemberEventContent =
387            serde_json::from_value(call_member_ev_json).unwrap();
388        assert_eq!(
389            serde_json::to_string(&ev_content).unwrap(),
390            serde_json::to_string(&call_member_ev).unwrap()
391        );
392        let empty = CallMemberEventContent::Empty(EmptyMembershipData { leave_reason: None });
393        assert_eq!(
394            serde_json::to_string(&json!({})).unwrap(),
395            serde_json::to_string(&empty).unwrap()
396        );
397    }
398
399    #[test]
400    fn deserialize_legacy_call_member_event_content() {
401        let call_member_ev = CallMemberEventContent::new_legacy(vec![
402            LegacyMembershipData {
403                application: Application::Call(CallApplicationContent {
404                    call_id: "123456".to_owned(),
405                    scope: CallScope::Room,
406                }),
407                device_id: owned_device_id!("THIS_DEVICE"),
408                expires: Duration::from_secs(3600),
409                foci_active: vec![Focus::Livekit(LivekitFocus {
410                    alias: "room1".to_owned(),
411                    service_url: "https://livekit1.com".to_owned(),
412                })],
413                membership_id: "0".to_owned(),
414                created_ts: None,
415            },
416            LegacyMembershipData {
417                application: Application::Call(CallApplicationContent {
418                    call_id: "".to_owned(),
419                    scope: CallScope::Room,
420                }),
421                device_id: owned_device_id!("OTHER_DEVICE"),
422                expires: Duration::from_secs(3600),
423                foci_active: vec![Focus::Livekit(LivekitFocus {
424                    alias: "room2".to_owned(),
425                    service_url: "https://livekit2.com".to_owned(),
426                })],
427                membership_id: "0".to_owned(),
428                created_ts: None,
429            },
430        ]);
431
432        let call_member_ev_json = json!({
433            "memberships": [
434                {
435                    "application": "m.call",
436                    "call_id": "123456",
437                    "scope": "m.room",
438                    "device_id": "THIS_DEVICE",
439                    "expires": 3_600_000,
440                    "foci_active": [
441                        {
442                            "livekit_alias": "room1",
443                            "livekit_service_url": "https://livekit1.com",
444                            "type": "livekit"
445                        }
446                    ],
447                    "membershipID": "0",
448                },
449                {
450                    "application": "m.call",
451                    "call_id": "",
452                    "scope": "m.room",
453                    "device_id": "OTHER_DEVICE",
454                    "expires": 3_600_000,
455                    "foci_active": [
456                        {
457                            "livekit_alias": "room2",
458                            "livekit_service_url": "https://livekit2.com",
459                            "type": "livekit"
460                        }
461                    ],
462                    "membershipID": "0"
463                }
464            ]
465        });
466
467        let ev_content: CallMemberEventContent =
468            serde_json::from_value(call_member_ev_json).unwrap();
469        assert_eq!(
470            serde_json::to_string(&ev_content).unwrap(),
471            serde_json::to_string(&call_member_ev).unwrap()
472        );
473    }
474
475    fn member_event_json(state_key: &str) -> JsonValue {
476        json!({
477            "content":{
478                "application": "m.call",
479                "call_id": "",
480                "scope": "m.room",
481                "device_id": "THIS_DEVICE",
482                "focus_active":{
483                    "type": "livekit",
484                    "focus_selection": "oldest_membership"
485                },
486                "foci_preferred": [
487                    {
488                        "livekit_alias": "room1",
489                        "livekit_service_url": "https://livekit1.com",
490                        "type": "livekit"
491                    }
492                ],
493            },
494            "type": "m.call.member",
495            "origin_server_ts": 111,
496            "event_id": "$3qfxjGYSu4sL25FtR0ep6vePOc",
497            "room_id": "!1234:example.org",
498            "sender": "@user:example.org",
499            "state_key": state_key,
500            "unsigned":{
501                "age":10,
502                "prev_content": {},
503                "prev_sender":"@user:example.org",
504            }
505        })
506    }
507
508    fn deserialize_member_event_helper(state_key: &str) {
509        let ev = member_event_json(state_key);
510
511        assert_matches!(
512            from_json_value(ev),
513            Ok(AnyStateEvent::CallMember(StateEvent::Original(member_event)))
514        );
515
516        let event_id = OwnedEventId::try_from("$3qfxjGYSu4sL25FtR0ep6vePOc").unwrap();
517        let sender = OwnedUserId::try_from("@user:example.org").unwrap();
518        let room_id = OwnedRoomId::try_from("!1234:example.org").unwrap();
519        assert_eq!(member_event.state_key.as_ref(), state_key);
520        assert_eq!(member_event.event_id, event_id);
521        assert_eq!(member_event.sender, sender);
522        assert_eq!(member_event.room_id, room_id);
523        assert_eq!(member_event.origin_server_ts, TS(js_int::UInt::new(111).unwrap()));
524        let membership = SessionMembershipData {
525            application: Application::Call(CallApplicationContent {
526                call_id: "".to_owned(),
527                scope: CallScope::Room,
528            }),
529            device_id: owned_device_id!("THIS_DEVICE"),
530            foci_preferred: [Focus::Livekit(LivekitFocus {
531                alias: "room1".to_owned(),
532                service_url: "https://livekit1.com".to_owned(),
533            })]
534            .to_vec(),
535            focus_active: ActiveFocus::Livekit(ActiveLivekitFocus {
536                focus_selection: FocusSelection::OldestMembership,
537            }),
538            created_ts: None,
539        };
540        assert_eq!(
541            member_event.content,
542            CallMemberEventContent::SessionContent(membership.clone())
543        );
544
545        // Correctly computes the active_memberships array.
546        assert_eq!(
547            member_event.content.active_memberships(None)[0],
548            vec![MembershipData::Session(&membership)][0]
549        );
550        assert_eq!(js_int::Int::new(10), member_event.unsigned.age);
551        assert_eq!(
552            CallMemberEventContent::Empty(EmptyMembershipData { leave_reason: None }),
553            member_event.unsigned.prev_content.unwrap()
554        );
555
556        // assert_eq!(, StateUnsigned { age: 10, transaction_id: None, prev_content:
557        // CallMemberEventContent::Empty { leave_reason: None }, relations: None })
558    }
559
560    #[test]
561    fn deserialize_member_event() {
562        deserialize_member_event_helper("@user:example.org");
563    }
564
565    #[test]
566    fn deserialize_member_event_with_scoped_state_key_prefixed() {
567        deserialize_member_event_helper("_@user:example.org_THIS_DEVICE");
568    }
569
570    #[test]
571    fn deserialize_member_event_with_scoped_state_key_unprefixed() {
572        deserialize_member_event_helper("@user:example.org_THIS_DEVICE");
573    }
574
575    fn timestamps() -> (TS, TS, TS) {
576        let now = TS::now();
577        let one_second_ago =
578            now.to_system_time().unwrap().checked_sub(Duration::from_secs(1)).unwrap();
579        let two_hours_ago =
580            now.to_system_time().unwrap().checked_sub(Duration::from_secs(60 * 60 * 2)).unwrap();
581        (
582            now,
583            TS::from_system_time(one_second_ago).unwrap(),
584            TS::from_system_time(two_hours_ago).unwrap(),
585        )
586    }
587
588    #[test]
589    fn legacy_memberships_do_expire() {
590        let content_legacy = create_call_member_legacy_event_content();
591        let (now, one_second_ago, two_hours_ago) = timestamps();
592
593        assert_eq!(
594            content_legacy.active_memberships(Some(one_second_ago)),
595            content_legacy.memberships()
596        );
597        assert_eq!(content_legacy.active_memberships(Some(now)), content_legacy.memberships());
598        assert_eq!(
599            content_legacy.active_memberships(Some(two_hours_ago)),
600            (vec![] as Vec<MembershipData<'_>>)
601        );
602        // session do never expire
603        let content_session = create_call_member_event_content();
604        assert_eq!(
605            content_session.active_memberships(Some(one_second_ago)),
606            content_session.memberships()
607        );
608        assert_eq!(content_session.active_memberships(Some(now)), content_session.memberships());
609        assert_eq!(
610            content_session.active_memberships(Some(two_hours_ago)),
611            content_session.memberships()
612        );
613    }
614
615    #[test]
616    fn set_created_ts() {
617        let mut content_now = create_call_member_legacy_event_content();
618        let mut content_two_hours_ago = create_call_member_legacy_event_content();
619        let mut content_one_second_ago = create_call_member_legacy_event_content();
620        let (now, one_second_ago, two_hours_ago) = timestamps();
621
622        content_now.set_created_ts_if_none(now);
623        content_one_second_ago.set_created_ts_if_none(one_second_ago);
624        content_two_hours_ago.set_created_ts_if_none(two_hours_ago);
625        assert_eq!(content_now.active_memberships(None), content_now.memberships());
626
627        assert_eq!(
628            content_two_hours_ago.active_memberships(None),
629            vec![] as Vec<MembershipData<'_>>
630        );
631        assert_eq!(
632            content_one_second_ago.active_memberships(None),
633            content_one_second_ago.memberships()
634        );
635
636        // created_ts should not be overwritten.
637        content_two_hours_ago.set_created_ts_if_none(one_second_ago);
638        // There still should be no active membership.
639        assert_eq!(
640            content_two_hours_ago.active_memberships(None),
641            vec![] as Vec<MembershipData<'_>>
642        );
643    }
644
645    #[test]
646    fn test_parse_rtc_member_event_key() {
647        assert!(from_json_value::<AnyStateEvent>(member_event_json("abc")).is_err());
648        assert!(from_json_value::<AnyStateEvent>(member_event_json("@nocolon")).is_err());
649        assert!(from_json_value::<AnyStateEvent>(member_event_json("@noserverpart:")).is_err());
650        assert!(
651            from_json_value::<AnyStateEvent>(member_event_json("@noserverpart:_suffix")).is_err()
652        );
653
654        let user_id = user_id!("@username:example.org").as_str();
655        let device_id = device_id!("VALID_DEVICE_ID").as_str();
656
657        let parse_result = from_json_value::<AnyStateEvent>(member_event_json(user_id));
658        assert_matches!(parse_result, Ok(_));
659        assert_matches!(
660            from_json_value::<AnyStateEvent>(member_event_json(&format!("{user_id}_{device_id}"))),
661            Ok(_)
662        );
663
664        assert_matches!(
665            from_json_value::<AnyStateEvent>(member_event_json(&format!(
666                "{user_id}:invalid_suffix"
667            ))),
668            Err(_)
669        );
670
671        assert_matches!(
672            from_json_value::<AnyStateEvent>(member_event_json(&format!("_{user_id}"))),
673            Err(_)
674        );
675
676        assert_matches!(
677            from_json_value::<AnyStateEvent>(member_event_json(&format!("_{user_id}_{device_id}"))),
678            Ok(_)
679        );
680
681        assert_matches!(
682            from_json_value::<AnyStateEvent>(member_event_json(&format!(
683                "_{user_id}:invalid_suffix"
684            ))),
685            Err(_)
686        );
687        assert_matches!(
688            from_json_value::<AnyStateEvent>(member_event_json(&format!("{user_id}_"))),
689            Err(_)
690        );
691    }
692}