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