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