Skip to main content

mail4agent_messenger/room/
state.rs

1//! [`RoomState`] -- a room's current state, folded from `m.room.*` state
2//! events (research doc §1.2, §1.4's `state.events`/`invite_state.events`),
3//! plus [`RoomKind`] derivation and this crate's own coarse [`MemberRole`]
4//! mapping.
5//!
6//! # Forward compatible by default
7//!
8//! [`RoomState::apply_state_event`] never fails on a state event type it
9//! does not model (the same doctrine `crate::wire::events` already
10//! follows) -- it only returns an error when a *recognized* type's
11//! `content` fails to parse against the shape `crate::wire::events` already
12//! defines for it.
13
14use crate::error::MessengerError;
15use crate::ids::{EventId, RoomId, UserId};
16use crate::wire::events::{
17    DirectContent, HistoryVisibility, JoinRule, Membership, RawEvent, RoomCreateContent, RoomEncryptionContent,
18    RoomHistoryVisibilityContent, RoomJoinRulesContent, RoomMemberContent, RoomNameContent, RoomPinnedEventsContent,
19    RoomPowerLevelsContent, RoomPowerLevelsNotifications, RoomTopicContent,
20};
21use crate::wire::sync::{RoomSummary as SyncRoomSummary, UnreadNotifications};
22use serde::{Deserialize, Serialize};
23use std::collections::BTreeMap;
24
25const EVENT_ROOM_CREATE: &str = "m.room.create";
26const EVENT_ROOM_MEMBER: &str = "m.room.member";
27const EVENT_ROOM_POWER_LEVELS: &str = "m.room.power_levels";
28const EVENT_ROOM_JOIN_RULES: &str = "m.room.join_rules";
29const EVENT_ROOM_HISTORY_VISIBILITY: &str = "m.room.history_visibility";
30const EVENT_ROOM_NAME: &str = "m.room.name";
31const EVENT_ROOM_TOPIC: &str = "m.room.topic";
32const EVENT_ROOM_ENCRYPTION: &str = "m.room.encryption";
33const EVENT_ROOM_PINNED_EVENTS: &str = "m.room.pinned_events";
34
35/// Coarse role from a member's power level. A public room's read-only
36/// follower has no power level to derive a role from, so that case is not
37/// modeled here.
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum MemberRole {
40    /// Power level >= 100 -- room-creator/admin tier.
41    Owner,
42    /// Power level >= 50 -- moderator tier.
43    Admin,
44    /// Everyone else.
45    Member,
46}
47
48impl MemberRole {
49    /// Maps a raw power level to its coarse role. Thresholds match the
50    /// Matrix convention cited in this type's own doc: `100` = owner, `50`
51    /// = admin, anything else = plain member.
52    pub fn from_power_level(power_level: i64) -> Self {
53        if power_level >= 100 {
54            MemberRole::Owner
55        } else if power_level >= 50 {
56            MemberRole::Admin
57        } else {
58            MemberRole::Member
59        }
60    }
61}
62
63/// What kind of room this is, derived from its current state -- never a
64/// separately stored flag (see [`RoomState::derive_room_kind`]'s own doc
65/// for the precedence between the two possible signals).
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub enum RoomKind {
68    /// A 1:1 conversation.
69    Dm,
70    /// A room with no channel signal -- an ordinary multi-person
71    /// conversation.
72    Group,
73    /// A public, announcement-style room: anyone may join, but only a
74    /// power level above `users_default` may post.
75    Channel,
76}
77
78/// One user's membership-derived state within a room (`m.room.member`'s
79/// content, research doc §1.2), keyed by that user's id in
80/// [`RoomState::members`].
81#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
82pub struct MemberState {
83    /// The subject's current membership.
84    pub membership: Membership,
85    /// The subject's display name, if set.
86    pub displayname: Option<String>,
87    /// `true` when this membership was flagged as part of a direct-message
88    /// room at invite time -- one of [`RoomState::derive_room_kind`]'s two
89    /// DM signals.
90    pub is_direct: bool,
91    /// Who sent this membership event when it is an invite (the inviter); `None` otherwise or
92    /// for state persisted before this field existed.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub invited_by: Option<UserId>,
95}
96
97/// One room's current state, folded from every state event
98/// [`RoomState::apply_state_event`] has been given so far. `Serialize`/
99/// `Deserialize` back this type's own persisted record
100/// (`crate::store`'s `room_state/{room_id}` key, restored by
101/// [`crate::core::MessengerCore::open`]) — unlike [`crate::room::timeline::Timeline`],
102/// which is never persisted and is always re-fetched instead (that type's
103/// own module doc).
104#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
105pub struct RoomState {
106    /// `m.room.create`'s content, once seen. Immutable once set: a room's
107    /// creation event is never legitimately resent with different content,
108    /// so [`RoomState::apply_state_event`] only ever accepts the first
109    /// one.
110    pub create: Option<RoomCreateContent>,
111    /// Every user this room has ever recorded a membership state for,
112    /// keyed by user id. A user who left/was banned stays in this map
113    /// (with that membership) rather than being removed -- their history
114    /// is still relevant (e.g. a past member's messages still need a
115    /// sender to resolve against).
116    pub members: BTreeMap<UserId, MemberState>,
117    /// The room's current permission table, spec-defaulted until an
118    /// `m.room.power_levels` event overrides it (`RoomPowerLevelsContent`'s
119    /// own field docs list each default).
120    pub power_levels: RoomPowerLevelsContent,
121    /// `m.room.join_rules`'s content, if set.
122    pub join_rules: Option<RoomJoinRulesContent>,
123    /// `m.room.history_visibility`'s value, if set.
124    pub history_visibility: Option<HistoryVisibility>,
125    /// `m.room.name`'s value, if set.
126    pub name: Option<String>,
127    /// `m.room.topic`'s value, if set.
128    pub topic: Option<String>,
129    /// `m.room.encryption`'s content, once set -- see
130    /// [`RoomState::apply_state_event`]'s doc for why this never changes
131    /// once `Some`.
132    pub encryption: Option<RoomEncryptionContent>,
133    /// How many times [`RoomState::apply_state_event`] has seen an
134    /// `m.room.encryption` event that tried to change an already-set
135    /// value to something different -- `0` in the overwhelming common
136    /// case. This crate has no logging dependency, so a nonzero count IS
137    /// the flag a caller acts on (a misbehaving or compromised server),
138    /// rather than the attempt being silently swallowed.
139    pub encryption_tamper_attempts: u32,
140    /// `m.room.pinned_events`'s content, in pin order.
141    pub pinned_events: Vec<EventId>,
142    /// `rooms.join.{room}.summary.m.heroes` from the most recent `/sync`
143    /// response that included this room's summary (research doc §1.4) --
144    /// up to five other members' user ids, used to synthesize a display
145    /// name when [`RoomState::name`] is unset.
146    pub heroes: Vec<UserId>,
147    /// `summary.m.joined_member_count`, if the server included it.
148    pub joined_member_count: Option<u64>,
149    /// `summary.m.invited_member_count`, if the server included it.
150    pub invited_member_count: Option<u64>,
151    /// The room's server-computed unread counters (research doc §1.4) --
152    /// [`RoomState::unread_count`] is a plain passthrough of
153    /// `notification_count`; real highlight detection for an encrypted
154    /// room is [`crate::room::timeline::highlight`], computed client-side
155    /// after decryption (plan manager decision #5).
156    pub unread_notifications: UnreadNotifications,
157}
158
159impl Default for RoomState {
160    fn default() -> Self {
161        Self {
162            create: None,
163            members: BTreeMap::new(),
164            power_levels: RoomPowerLevelsContent {
165                ban: 50,
166                events: BTreeMap::new(),
167                events_default: 0,
168                invite: 50,
169                kick: 50,
170                redact: 50,
171                state_default: 50,
172                users: BTreeMap::new(),
173                users_default: 0,
174                notifications: RoomPowerLevelsNotifications::default(),
175            },
176            join_rules: None,
177            history_visibility: None,
178            name: None,
179            topic: None,
180            encryption: None,
181            encryption_tamper_attempts: 0,
182            pinned_events: Vec::new(),
183            heroes: Vec::new(),
184            joined_member_count: None,
185            invited_member_count: None,
186            unread_notifications: UnreadNotifications::default(),
187        }
188    }
189}
190
191impl RoomState {
192    /// An empty room state: no state events folded yet, spec-default power
193    /// levels (matches a room whose `m.room.power_levels` has never been
194    /// sent).
195    pub fn new() -> Self {
196        Self::default()
197    }
198
199    /// Folds one state event into this room's state. A no-op, not an
200    /// error, for a timeline (non-state) event or a state event type this
201    /// module does not model (module doc's forward-compatibility
202    /// doctrine). Returns an error only when a recognized type's `content`
203    /// fails to parse.
204    pub fn apply_state_event(&mut self, event: &RawEvent) -> Result<(), MessengerError> {
205        let Some(state_key) = event.state_key.as_deref() else { return Ok(()) };
206        match event.event_type.as_str() {
207            EVENT_ROOM_CREATE if self.create.is_none() => {
208                self.create = Some(serde_json::from_value(event.content.clone())?);
209            }
210            EVENT_ROOM_MEMBER => {
211                let Ok(user_id) = UserId::parse(state_key) else { return Ok(()) };
212                let content: RoomMemberContent = serde_json::from_value(event.content.clone())?;
213                let invited_by = (content.membership == Membership::Invite).then(|| event.sender.clone());
214                self.members.insert(
215                    user_id,
216                    MemberState {
217                        membership: content.membership,
218                        displayname: content.displayname,
219                        is_direct: content.is_direct.unwrap_or(false),
220                        invited_by,
221                    },
222                );
223            }
224            EVENT_ROOM_POWER_LEVELS => {
225                self.power_levels = serde_json::from_value(event.content.clone())?;
226            }
227            EVENT_ROOM_JOIN_RULES => {
228                self.join_rules = Some(serde_json::from_value(event.content.clone())?);
229            }
230            EVENT_ROOM_HISTORY_VISIBILITY => {
231                let content: RoomHistoryVisibilityContent = serde_json::from_value(event.content.clone())?;
232                self.history_visibility = Some(content.history_visibility);
233            }
234            EVENT_ROOM_NAME => {
235                let content: RoomNameContent = serde_json::from_value(event.content.clone())?;
236                self.name = Some(content.name);
237            }
238            EVENT_ROOM_TOPIC => {
239                let content: RoomTopicContent = serde_json::from_value(event.content.clone())?;
240                self.topic = Some(content.topic);
241            }
242            EVENT_ROOM_ENCRYPTION => {
243                let content: RoomEncryptionContent = serde_json::from_value(event.content.clone())?;
244                match &self.encryption {
245                    None => self.encryption = Some(content),
246                    Some(current) if current != &content => {
247                        // Spec invariant: once set, `m.room.encryption` is
248                        // never unset or changed to a different algorithm/
249                        // rotation policy (research doc §1.2). Enforcing
250                        // that server-side is not this crate's job, but the
251                        // client never adopts a later, different value --
252                        // it only counts the attempt (this type's own
253                        // doc).
254                        self.encryption_tamper_attempts += 1;
255                    }
256                    Some(_) => {} // identical resend -- not a tamper attempt
257                }
258            }
259            EVENT_ROOM_PINNED_EVENTS => {
260                let content: RoomPinnedEventsContent = serde_json::from_value(event.content.clone())?;
261                self.pinned_events = content.pinned;
262            }
263            _ => {}
264        }
265        Ok(())
266    }
267
268    /// Records this room's most recent `/sync` summary (heroes, member
269    /// counts) and unread counters (research doc §1.4) -- called once per
270    /// sync response that included this room, independent of
271    /// [`RoomState::apply_state_event`] since a summary/`unread_
272    /// notifications` block is not itself a state event.
273    pub fn apply_summary(&mut self, summary: &SyncRoomSummary, unread: &UnreadNotifications) {
274        self.heroes = summary.heroes.clone();
275        self.joined_member_count = summary.joined_member_count;
276        self.invited_member_count = summary.invited_member_count;
277        self.unread_notifications = unread.clone();
278    }
279
280    /// The room's server-reported unread notification count -- a plain
281    /// passthrough (plan manager decision #5: `notification_count` comes
282    /// from the server; only *highlight* is computed client-side).
283    pub fn unread_count(&self) -> u64 {
284        self.unread_notifications.notification_count
285    }
286
287    /// `user_id`'s effective power level: their own entry in
288    /// `power_levels.users`, or `power_levels.users_default` if they have
289    /// none.
290    pub fn power_level_for(&self, user_id: &UserId) -> i64 {
291        self.power_levels.users.get(user_id).copied().unwrap_or(self.power_levels.users_default)
292    }
293
294    /// `user_id`'s coarse role, derived from [`RoomState::power_level_for`]
295    /// via [`MemberRole::from_power_level`].
296    pub fn member_role(&self, user_id: &UserId) -> MemberRole {
297        MemberRole::from_power_level(self.power_level_for(user_id))
298    }
299
300    /// How many members currently hold an active (join or invite)
301    /// membership -- used by [`RoomState::derive_room_kind`]'s DM check,
302    /// and generally useful for a room-list "N members" line.
303    pub fn active_member_count(&self) -> usize {
304        self.members.values().filter(|member| matches!(member.membership, Membership::Join | Membership::Invite)).count()
305    }
306
307    /// Derives this room's [`RoomKind`] from its current state.
308    ///
309    /// **Precedence: DM first, channel second, group as the fallback.** DM
310    /// wins over channel because it is the more deliberate, identity-scoped
311    /// signal (an explicit `m.direct` account-data entry, or an invite
312    /// that was explicitly flagged `is_direct` and never grew past two
313    /// members) -- a room's power-level shape (the channel signal) says
314    /// nothing about whether it was *intended* as a 1:1, so a 2-person room
315    /// that happens to also be public with a raised `events_default`
316    /// should still read as a DM, not a channel of one.
317    ///
318    /// `direct_account_data` is the account-wide `m.direct` content
319    /// ([`DirectContent`]) -- global account data, not room state, so it is
320    /// not a field of this type and must be threaded in by the caller (the
321    /// sync engine, a later piece, already holds it after folding the
322    /// top-level `account_data` section).
323    pub fn derive_room_kind(&self, room_id: &RoomId, direct_account_data: Option<&DirectContent>) -> RoomKind {
324        if self.is_direct(room_id, direct_account_data) {
325            RoomKind::Dm
326        } else if self.is_channel() {
327            RoomKind::Channel
328        } else {
329            RoomKind::Group
330        }
331    }
332
333    fn is_direct(&self, room_id: &RoomId, direct_account_data: Option<&DirectContent>) -> bool {
334        let listed_in_account_data =
335            direct_account_data.is_some_and(|direct| direct.0.values().any(|rooms| rooms.contains(room_id)));
336        let invite_flagged_and_still_a_pair =
337            self.active_member_count() == 2 && self.members.values().any(|member| member.is_direct);
338        listed_in_account_data || invite_flagged_and_still_a_pair
339    }
340
341    fn is_channel(&self) -> bool {
342        let is_public =
343            matches!(self.join_rules.as_ref().map(|join_rules| &join_rules.join_rule), Some(JoinRule::Public));
344        is_public && self.power_levels.events_default > self.power_levels.users_default
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    fn state_event(event_type: &str, state_key: &str, sender: &str, content: serde_json::Value) -> RawEvent {
353        serde_json::from_value(serde_json::json!({
354            "event_id": "$e:example.org",
355            "type": event_type,
356            "sender": sender,
357            "origin_server_ts": 1,
358            "state_key": state_key,
359            "content": content,
360        }))
361        .expect("valid state event")
362    }
363
364    #[test]
365    fn derive_room_kind_dm_vs_group_vs_channel() {
366        let room_id = RoomId::parse("!room:example.org").expect("valid room id");
367
368        // Group: three joined members, no direct flag, no channel-shaped
369        // power levels.
370        let mut group = RoomState::new();
371        for user in ["@alice:example.org", "@bob:example.org", "@carol:example.org"] {
372            group
373                .apply_state_event(&state_event("m.room.member", user, user, serde_json::json!({ "membership": "join" })))
374                .expect("valid member event");
375        }
376        assert_eq!(group.derive_room_kind(&room_id, None), RoomKind::Group);
377
378        // DM: two joined members, one of whom was invited with `is_direct`.
379        let mut dm = RoomState::new();
380        dm.apply_state_event(&state_event(
381            "m.room.member",
382            "@alice:example.org",
383            "@alice:example.org",
384            serde_json::json!({ "membership": "join" }),
385        ))
386        .expect("valid member event");
387        dm.apply_state_event(&state_event(
388            "m.room.member",
389            "@bob:example.org",
390            "@alice:example.org",
391            serde_json::json!({ "membership": "join", "is_direct": true }),
392        ))
393        .expect("valid member event");
394        assert_eq!(dm.derive_room_kind(&room_id, None), RoomKind::Dm);
395
396        // Channel: public join rule, `events_default` raised above
397        // `users_default` (research doc §1.2's announcement-only pattern).
398        let mut channel = RoomState::new();
399        channel
400            .apply_state_event(&state_event(
401                "m.room.join_rules",
402                "",
403                "@alice:example.org",
404                serde_json::json!({ "join_rule": "public" }),
405            ))
406            .expect("valid join rules event");
407        channel
408            .apply_state_event(&state_event(
409                "m.room.power_levels",
410                "",
411                "@alice:example.org",
412                serde_json::json!({ "events_default": 50 }),
413            ))
414            .expect("valid power levels event");
415        assert_eq!(channel.derive_room_kind(&room_id, None), RoomKind::Channel);
416    }
417
418    #[test]
419    fn derive_room_kind_dm_via_account_data_overrides_channel_shape() {
420        let room_id = RoomId::parse("!room:example.org").expect("valid room id");
421        let alice = UserId::parse("@alice:example.org").expect("valid user id");
422
423        let mut room = RoomState::new();
424        room.apply_state_event(&state_event(
425            "m.room.join_rules",
426            "",
427            "@alice:example.org",
428            serde_json::json!({ "join_rule": "public" }),
429        ))
430        .expect("valid join rules event");
431        room.apply_state_event(&state_event(
432            "m.room.power_levels",
433            "",
434            "@alice:example.org",
435            serde_json::json!({ "events_default": 50 }),
436        ))
437        .expect("valid power levels event");
438
439        // Structurally this looks like a channel, but an explicit
440        // `m.direct` account-data entry takes precedence (this type's own
441        // doc: DM wins).
442        let mut direct_rooms: BTreeMap<UserId, Vec<RoomId>> = BTreeMap::new();
443        direct_rooms.insert(alice, vec![room_id.clone()]);
444        let direct_content = DirectContent(direct_rooms);
445
446        assert_eq!(room.derive_room_kind(&room_id, Some(&direct_content)), RoomKind::Dm);
447        assert_eq!(room.derive_room_kind(&room_id, None), RoomKind::Channel);
448    }
449
450    #[test]
451    fn encryption_state_cannot_be_unset() {
452        let mut room = RoomState::new();
453        let megolm = serde_json::json!({ "algorithm": "m.megolm.v1.aes-sha2" });
454        room.apply_state_event(&state_event("m.room.encryption", "", "@alice:example.org", megolm.clone()))
455            .expect("valid encryption event");
456        let original = room.encryption.clone().expect("encryption set");
457        assert_eq!(room.encryption_tamper_attempts, 0);
458
459        // A later attempt to change the rotation policy is ignored.
460        let tampered = serde_json::json!({ "algorithm": "m.megolm.v1.aes-sha2", "rotation_period_msgs": 1 });
461        room.apply_state_event(&state_event("m.room.encryption", "", "@mallory:example.org", tampered))
462            .expect("valid encryption event");
463        assert_eq!(room.encryption, Some(original));
464        assert_eq!(room.encryption_tamper_attempts, 1);
465
466        // Resending the exact same content is not counted as a tamper
467        // attempt.
468        room.apply_state_event(&state_event("m.room.encryption", "", "@alice:example.org", megolm))
469            .expect("valid encryption event");
470        assert_eq!(room.encryption_tamper_attempts, 1);
471    }
472
473    #[test]
474    fn member_role_uses_the_matrix_thresholds() {
475        let mut room = RoomState::new();
476        let owner = UserId::parse("@owner:example.org").expect("valid user id");
477        let admin = UserId::parse("@admin:example.org").expect("valid user id");
478        let plain = UserId::parse("@plain:example.org").expect("valid user id");
479        room.apply_state_event(&state_event(
480            "m.room.power_levels",
481            "",
482            "@owner:example.org",
483            serde_json::json!({ "users": { "@owner:example.org": 100, "@admin:example.org": 50 } }),
484        ))
485        .expect("valid power levels event");
486
487        assert_eq!(room.member_role(&owner), MemberRole::Owner);
488        assert_eq!(room.member_role(&admin), MemberRole::Admin);
489        assert_eq!(room.member_role(&plain), MemberRole::Member);
490    }
491}