Skip to main content

obby_client/
voice.rs

1//! Voice signalling: the `+obsidianirc/rtc` frame types and room state.
2//!
3//! This is the signalling and room-state plane only: parsing and building `+obsidianirc/rtc`
4//! frames, splitting and
5//! reassembling an oversized `offer`/`answer`, and tracking who is in a room and what they are
6//! doing. `RTCPeerConnection`, codecs and `getUserMedia` never appear here; an `sdp` value is an
7//! opaque string this module chunks and reassembles, never parses.
8//!
9//! The published <https://github.com/obbyworld/extensions/blob/main/voice.md> documents 9 of the
10//! 19 frame types actually on the
11//! wire, so [`Signal`] follows the wire. [`Signal::to_json`] and [`Signal::from_json`] implement that
12//! wire's JSON directly, by hand: this crate has no JSON library in its dependency graph to lean
13//! on, and the exact shape (three incompatible `presence` bodies under one `type`, chunk fields
14//! flattened onto `offer`/`answer` rather than nested, a redundant `state` on `speaking`/
15//! `silent`) is precise enough that a generic derive could not produce it without the same
16//! amount of per-field annotation this hand-written codec already is. The
17//! `#[cfg_attr(feature = "serde", derive(...))]` on every public type here is the same generic,
18//! non-wire convenience every other module in this crate offers; it does not attempt to match
19//! the wire shape.
20
21use alloc::collections::BTreeMap;
22use alloc::string::{String, ToString};
23use alloc::vec::Vec;
24use core::fmt::Write as _;
25
26use obby_proto::{CaseFolded, Casemapping};
27
28use crate::json::{Json, field_string};
29
30// ---------------------------------------------------------------------------------------------
31// Writing the JSON shapes this module puts on the wire. Reading them is `crate::json`.
32// ---------------------------------------------------------------------------------------------
33
34impl Json {
35    fn write(&self, out: &mut String) {
36        match self {
37            Json::Null => out.push_str("null"),
38            Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
39            Json::Num(n) => {
40                let _ = write!(out, "{n}");
41            }
42            Json::Str(s) => write_json_string(s, out),
43            Json::Array(items) => write_json_array(items, out),
44            Json::Object(fields) => write_json_object(fields, out),
45        }
46    }
47
48    fn as_u32(&self) -> Option<u32> {
49        match self {
50            Json::Num(n) => u32::try_from(*n).ok(),
51            _ => None,
52        }
53    }
54}
55
56fn write_json_array(items: &[Json], out: &mut String) {
57    out.push('[');
58    for (i, item) in items.iter().enumerate() {
59        if i > 0 {
60            out.push(',');
61        }
62        item.write(out);
63    }
64    out.push(']');
65}
66
67fn write_json_object(fields: &[(String, Json)], out: &mut String) {
68    out.push('{');
69    for (i, (key, value)) in fields.iter().enumerate() {
70        if i > 0 {
71            out.push(',');
72        }
73        write_json_string(key, out);
74        out.push(':');
75        value.write(out);
76    }
77    out.push('}');
78}
79
80fn write_json_string(s: &str, out: &mut String) {
81    out.push('"');
82    for c in s.chars() {
83        match c {
84            '"' => out.push_str("\\\""),
85            '\\' => out.push_str("\\\\"),
86            '\n' => out.push_str("\\n"),
87            '\r' => out.push_str("\\r"),
88            '\t' => out.push_str("\\t"),
89            c if (u32::from(c)) < 0x20 => {
90                let _ = write!(out, "\\u{:04x}", u32::from(c));
91            }
92            c => out.push(c),
93        }
94    }
95    out.push('"');
96}
97
98fn obj(pairs: Vec<(&str, Json)>) -> Json {
99    Json::Object(
100        pairs
101            .into_iter()
102            .map(|(key, value)| (key.to_string(), value))
103            .collect(),
104    )
105}
106
107fn opt_field(fields: &mut Vec<(String, Json)>, key: &str, value: Option<Json>) {
108    if let Some(value) = value {
109        fields.push((key.to_string(), value));
110    }
111}
112
113fn strings_json(items: &[String]) -> Json {
114    Json::Array(items.iter().map(|s| Json::Str(s.clone())).collect())
115}
116
117/// Every string in an array, or nothing when any element is not one.
118fn strings(value: &Json) -> Option<Vec<String>> {
119    value
120        .as_array()?
121        .iter()
122        .map(|item| item.as_str().map(str::to_string))
123        .collect()
124}
125
126fn field_string_opt(value: &Json, key: &str) -> Option<String> {
127    value.field(key).and_then(Json::as_str).map(str::to_string)
128}
129
130fn field_u32(value: &Json, key: &str) -> Option<u32> {
131    value.field(key)?.as_u32()
132}
133
134// ---------------------------------------------------------------------------------------------
135// Small enumerated wire values shared by several frame types.
136// ---------------------------------------------------------------------------------------------
137
138/// The two states an intent frame like `mic` or `hand` toggles between.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
141#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
142#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
143pub enum OnOff {
144    /// The feature is enabled.
145    On,
146    /// The feature is disabled.
147    Off,
148}
149
150impl OnOff {
151    fn wire(self) -> &'static str {
152        match self {
153            OnOff::On => "on",
154            OnOff::Off => "off",
155        }
156    }
157
158    fn parse(text: &str) -> Option<Self> {
159        match text {
160            "on" => Some(OnOff::On),
161            "off" => Some(OnOff::Off),
162            _ => None,
163        }
164    }
165}
166
167/// Whether a room participant may publish audio and video, or only receive it.
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
170#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
171#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
172#[cfg_attr(feature = "ts", ts(rename = "VoiceRole"))]
173pub enum Role {
174    /// May publish: everyone in a `^` room, and the streamer plus their promotions in a `$` room.
175    Publisher,
176    /// Receives only: never true in a `^` room; anyone not promoted in a `$` room.
177    Viewer,
178}
179
180impl Role {
181    fn wire(self) -> &'static str {
182        match self {
183            Role::Publisher => "streamer",
184            Role::Viewer => "viewer",
185        }
186    }
187
188    fn parse(text: &str) -> Option<Self> {
189        match text {
190            "streamer" => Some(Role::Publisher),
191            "viewer" => Some(Role::Viewer),
192            _ => None,
193        }
194    }
195}
196
197/// Who may publish in a voice room, decided by the channel's sigil.
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
200#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
201#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
202#[cfg_attr(feature = "ts", ts(rename = "VoiceRoomKind"))]
203pub enum RoomKind {
204    /// A `^` channel: every member publishes their own microphone for free.
205    Publish,
206    /// A `$` channel: a streamer and whoever they promote publish; everyone else watches.
207    Stream,
208}
209
210impl RoomKind {
211    fn wire(self) -> &'static str {
212        match self {
213            RoomKind::Publish => "voice",
214            RoomKind::Stream => "stream",
215        }
216    }
217
218    fn parse(text: &str) -> Option<Self> {
219        match text {
220            "voice" => Some(RoomKind::Publish),
221            "stream" => Some(RoomKind::Stream),
222            _ => None,
223        }
224    }
225
226    /// The kind a channel's own name implies: `$` streams, everything else publishes.
227    fn for_channel(channel: &str) -> Self {
228        if channel.starts_with('$') {
229            RoomKind::Stream
230        } else {
231            RoomKind::Publish
232        }
233    }
234}
235
236/// Which per-participant toggle a `presence` notification reports, for its toggle sub-shape.
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
239#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
240#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
241#[cfg_attr(feature = "ts", ts(rename = "VoiceToggle"))]
242pub enum ToggleKind {
243    /// Microphone.
244    Mic,
245    /// Camera.
246    Video,
247    /// Screen share.
248    Screen,
249    /// Raised hand.
250    Hand,
251}
252
253impl ToggleKind {
254    fn wire(self) -> &'static str {
255        match self {
256            ToggleKind::Mic => "mic",
257            ToggleKind::Video => "video",
258            ToggleKind::Screen => "screen",
259            ToggleKind::Hand => "hand",
260        }
261    }
262
263    fn parse(text: &str) -> Option<Self> {
264        match text {
265            "mic" => Some(ToggleKind::Mic),
266            "video" => Some(ToggleKind::Video),
267            "screen" => Some(ToggleKind::Screen),
268            "hand" => Some(ToggleKind::Hand),
269            _ => None,
270        }
271    }
272
273    fn apply(self, participant: &mut Participant, state: OnOff) {
274        match self {
275            ToggleKind::Mic => participant.mic = state,
276            ToggleKind::Video => participant.video = state,
277            ToggleKind::Screen => participant.screen = state,
278            ToggleKind::Hand => participant.hand = state,
279        }
280    }
281}
282
283/// The `state` a `presence` notification carries.
284///
285/// One wire `type: "presence"` actually carries three incompatible shapes: a membership change (`Joined`/`Left`), a toggle
286/// (`On`/`Off`, read together with [`Signal::Presence`]'s `kind`), or an activity flag
287/// (`Speaking`/`Silent`/`DeafOn`/`DeafOff`, which carries no `kind` at all).
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
290#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
291#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
292#[cfg_attr(feature = "ts", ts(rename = "VoicePresence"))]
293pub enum PresenceState {
294    /// `member` joined the room. Carries a `role` only in a `$` room.
295    Joined,
296    /// `member` left the room.
297    Left,
298    /// The toggle named by `kind` turned on.
299    On,
300    /// The toggle named by `kind` turned off.
301    Off,
302    /// `member` started talking, as detected by their own voice-activity detector.
303    Speaking,
304    /// `member` stopped talking.
305    Silent,
306    /// `member` deafened themself.
307    DeafOn,
308    /// `member` un-deafened themself.
309    DeafOff,
310}
311
312impl PresenceState {
313    fn wire(self) -> &'static str {
314        match self {
315            PresenceState::Joined => "joined",
316            PresenceState::Left => "left",
317            PresenceState::On => "on",
318            PresenceState::Off => "off",
319            PresenceState::Speaking => "speaking",
320            PresenceState::Silent => "silent",
321            PresenceState::DeafOn => "deaf-on",
322            PresenceState::DeafOff => "deaf-off",
323        }
324    }
325
326    fn parse(text: &str) -> Option<Self> {
327        Some(match text {
328            "joined" => PresenceState::Joined,
329            "left" => PresenceState::Left,
330            "on" => PresenceState::On,
331            "off" => PresenceState::Off,
332            "speaking" => PresenceState::Speaking,
333            "silent" => PresenceState::Silent,
334            "deaf-on" => PresenceState::DeafOn,
335            "deaf-off" => PresenceState::DeafOff,
336            _ => return None,
337        })
338    }
339}
340
341// ---------------------------------------------------------------------------------------------
342// Supporting frame payloads.
343// ---------------------------------------------------------------------------------------------
344
345/// A hint from the SFU mapping one negotiated media line to the member it belongs to.
346///
347/// The SFU sends mid-to-member hints so an inbound track can be attributed to the right member
348/// when the SDP's own `msid` is missing or unreliable. This is the minimal shape that serves that
349/// purpose; a real server may send more fields, which this simply ignores on decode.
350#[derive(Debug, Clone, PartialEq, Eq)]
351#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
352#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
353#[cfg_attr(feature = "ts", ts(rename = "VoiceTrackHint"))]
354pub struct TrackHint {
355    /// The SDP media line identifier this hint names.
356    pub mid: String,
357    /// The member that media line belongs to.
358    pub member: String,
359}
360
361impl TrackHint {
362    fn to_json(&self) -> Json {
363        obj(alloc::vec![
364            ("mid", Json::Str(self.mid.clone())),
365            ("member", Json::Str(self.member.clone())),
366        ])
367    }
368
369    fn from_json(value: &Json) -> Option<Self> {
370        Some(Self {
371            mid: field_string(value, "mid")?,
372            member: field_string(value, "member")?,
373        })
374    }
375}
376
377/// TURN/STUN credentials the SFU hands us on `joined`.
378///
379/// These are short-lived, and nothing in the `joined` handshake or anywhere else in the
380/// signalling plane ever refreshes them mid-call. A call that outlives them loses its relay path
381/// with no warning;
382/// whoever integrates this signalling plane needs to leave and rejoin (or otherwise trigger a
383/// fresh `joined`) before that happens, since nothing here does it automatically.
384#[derive(Debug, Clone, PartialEq, Eq)]
385#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
386#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
387pub struct TurnCredentials {
388    /// The TURN/STUN server URLs to try, in order.
389    pub urls: Vec<String>,
390    /// The short-lived TURN username.
391    pub username: String,
392    /// The short-lived TURN password.
393    pub password: String,
394}
395
396impl TurnCredentials {
397    fn to_json(&self) -> Json {
398        obj(alloc::vec![
399            ("urls", strings_json(&self.urls)),
400            ("username", Json::Str(self.username.clone())),
401            ("password", Json::Str(self.password.clone())),
402        ])
403    }
404
405    fn from_json(value: &Json) -> Option<Self> {
406        let urls = match value.field("urls")? {
407            Json::Str(one) => alloc::vec![one.clone()],
408            many @ Json::Array(_) => strings(many)?,
409            _ => return None,
410        };
411        Some(Self {
412            urls,
413            username: field_string(value, "username")?,
414            password: field_string(value, "password")?,
415        })
416    }
417}
418
419/// The chunk-correlation fields riding alongside a split `sdp` value.
420#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
421#[derive(Debug, Clone, PartialEq, Eq)]
422#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
423pub struct ChunkMeta {
424    /// The id every chunk of one split frame shares.
425    pub id: String,
426    /// This chunk's 0-based position.
427    pub seq: u32,
428    /// How many chunks the split frame was cut into.
429    pub total: u32,
430}
431
432fn decode_chunk(value: &Json) -> Option<ChunkMeta> {
433    Some(ChunkMeta {
434        id: field_string_opt(value, "id")?,
435        seq: field_u32(value, "seq")?,
436        total: field_u32(value, "total")?,
437    })
438}
439
440fn chunk_meta_fields(chunk: &ChunkMeta) -> Vec<(String, Json)> {
441    alloc::vec![
442        ("id".to_string(), Json::Str(chunk.id.clone())),
443        ("seq".to_string(), Json::Num(i64::from(chunk.seq))),
444        ("total".to_string(), Json::Num(i64::from(chunk.total))),
445    ]
446}
447
448// ---------------------------------------------------------------------------------------------
449// Signal: every `+obsidianirc/rtc` frame type.
450// ---------------------------------------------------------------------------------------------
451
452/// One `+obsidianirc/rtc` signalling frame.
453///
454/// Every variant is one JSON object's `type`. The published
455/// <https://github.com/obbyworld/extensions/blob/main/voice.md> documents only 9 of these, the
456/// wire carries 19, and this enum follows the wire. Outbound intent and inbound notification are modelled as distinct shapes where the
457/// wire actually distinguishes them (`mic`/`video`/`screen`/`hand`/`speaking`/`silent`/`deaf`
458/// versus the `presence` they get rebroadcast as; `promote`/`demote` versus `role`), rather than
459/// collapsed into one type as the published table's prose implies.
460#[derive(Debug, Clone, PartialEq, Eq)]
461#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
462#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
463#[cfg_attr(feature = "ts", ts(rename = "VoiceSignal"))]
464#[cfg_attr(feature = "serde", serde(tag = "type", rename_all = "snake_case"))]
465#[non_exhaustive]
466pub enum Signal {
467    /// Ask to join a voice room. Client to server.
468    Join {
469        /// The channel to join.
470        channel: String,
471    },
472    /// Leave a voice room. Client to server.
473    ///
474    /// A reconnect should send this before rejoining: a dropped link otherwise leaves the SFU
475    /// holding a dead peer for this nick, which answers a plain `join` with "already joined"
476    /// rather than a fresh handshake.
477    Leave {
478        /// The channel to leave.
479        channel: String,
480    },
481    /// The server admitted us to the room. Server to client.
482    Joined {
483        /// Every current member's nick.
484        members: Vec<String>,
485        /// The room's kind, present only for a `$` channel.
486        mode: Option<RoomKind>,
487        /// The role granted to us specifically, present only for a `$` channel.
488        role: Option<Role>,
489        /// Who currently publishes, for a `$` channel.
490        streamers: Option<Vec<String>>,
491        /// The TURN credentials for this call.
492        turn: Option<TurnCredentials>,
493        /// Hints mapping media lines to the members they belong to.
494        tracks: Option<Vec<TrackHint>>,
495    },
496    /// An SDP offer, from either side: ours to the SFU, or the SFU renegotiating with us.
497    Offer {
498        /// The offer SDP, or one slice of it when `chunk` is set.
499        sdp: String,
500        /// Track-attribution hints, carried only on chunk 0 or an unchunked offer.
501        tracks: Option<Vec<TrackHint>>,
502        /// Set when this frame is one of several chunks sharing an id.
503        chunk: Option<ChunkMeta>,
504    },
505    /// An SDP answer, from either side.
506    Answer {
507        /// The answer SDP, or one slice of it when `chunk` is set.
508        sdp: String,
509        /// Set when this frame is one of several chunks sharing an id.
510        chunk: Option<ChunkMeta>,
511    },
512    /// One ICE candidate, from either side.
513    Ice {
514        /// The candidate string.
515        cand: String,
516        /// The media line it applies to.
517        mid: Option<String>,
518        /// The media line's index, when `mid` is absent.
519        mlineidx: Option<u32>,
520    },
521    /// A room or participant state change. Server to client only.
522    Presence {
523        /// Who this is about.
524        member: String,
525        /// What changed.
526        state: PresenceState,
527        /// Which toggle, when `state` is [`PresenceState::On`] or [`PresenceState::Off`].
528        kind: Option<ToggleKind>,
529        /// The role granted, when `state` is [`PresenceState::Joined`] in a `$` room.
530        role: Option<Role>,
531    },
532    /// Our microphone toggled. Client to server; the server rebroadcasts it as `presence`.
533    Mic {
534        /// The new state.
535        state: OnOff,
536    },
537    /// Our camera toggled.
538    Video {
539        /// The new state.
540        state: OnOff,
541    },
542    /// Our screen share toggled.
543    Screen {
544        /// The new state.
545        state: OnOff,
546    },
547    /// Our raised hand toggled.
548    Hand {
549        /// The new state.
550        state: OnOff,
551    },
552    /// Our deafen toggled.
553    ///
554    /// A room learns that someone deafened only from this frame, so we send it the way we send
555    /// `mic`, `video`, `screen` and `hand`, and the room applies the matching `presence`
556    /// `deaf-on`/`deaf-off` like every other toggle.
557    Deaf {
558        /// The new state.
559        state: OnOff,
560    },
561    /// Our own voice-activity detector says we started talking.
562    Speaking,
563    /// Our own voice-activity detector says we stopped talking.
564    Silent,
565    /// React with an emoji. Outbound carries no `member` (the sender is implicit); the server's
566    /// rebroadcast adds it.
567    React {
568        /// Who reacted, present only on the inbound broadcast.
569        member: Option<String>,
570        /// The emoji.
571        emoji: String,
572    },
573    /// Ask to promote `target` to publisher, in a `$` room. Client to server.
574    ///
575    /// Only the room's first streamer may do this, and self-demotion is always allowed, but that
576    /// rule is enforced only by the server: nothing stops a client from sending this frame
577    /// regardless.
578    Promote {
579        /// The member to promote.
580        target: String,
581    },
582    /// Ask to demote `target` back to viewer, in a `$` room. Client to server.
583    Demote {
584        /// The member to demote.
585        target: String,
586    },
587    /// The server's authoritative answer to a `promote` or `demote`. Server to client.
588    Role {
589        /// Whose role changed.
590        member: String,
591        /// Their new role.
592        role: Role,
593    },
594    /// The server rejected the last request.
595    Error {
596        /// A human-readable reason, when the server gave one.
597        error: Option<String>,
598    },
599}
600
601impl Signal {
602    /// Encode this frame as the JSON object that travels on the wire.
603    pub fn to_json(&self) -> String {
604        let mut out = String::new();
605        self.to_json_value().write(&mut out);
606        out
607    }
608
609    /// Decode one wire JSON object into a frame.
610    ///
611    /// Returns `None` for text that is not valid JSON, an object missing a field its `type`
612    /// requires, or a `type` this module does not know.
613    pub fn from_json(text: &str) -> Option<Self> {
614        Self::from_value(&Json::parse(text)?)
615    }
616
617    fn to_json_value(&self) -> Json {
618        let (kind, mut fields) = self.wire_fields();
619        fields.insert(0, ("type".to_string(), Json::Str(kind.to_string())));
620        Json::Object(fields)
621    }
622
623    fn wire_fields(&self) -> (&'static str, Vec<(String, Json)>) {
624        match self {
625            Signal::Join { channel } => ("join", channel_fields(channel)),
626            Signal::Leave { channel } => ("leave", channel_fields(channel)),
627            Signal::Joined {
628                members,
629                mode,
630                role,
631                streamers,
632                turn,
633                tracks,
634            } => (
635                "joined",
636                joined_fields(
637                    members,
638                    *mode,
639                    *role,
640                    streamers.as_deref(),
641                    turn.as_ref(),
642                    tracks.as_deref(),
643                ),
644            ),
645            Signal::Offer { sdp, tracks, chunk } => (
646                "offer",
647                offer_fields(sdp, tracks.as_deref(), chunk.as_ref()),
648            ),
649            Signal::Answer { sdp, chunk } => ("answer", answer_fields(sdp, chunk.as_ref())),
650            Signal::Ice {
651                cand,
652                mid,
653                mlineidx,
654            } => ("ice", ice_fields(cand, mid.as_deref(), *mlineidx)),
655            Signal::Presence {
656                member,
657                state,
658                kind,
659                role,
660            } => ("presence", presence_fields(member, *state, *kind, *role)),
661            Signal::Mic { state } => ("mic", state_field(*state)),
662            Signal::Video { state } => ("video", state_field(*state)),
663            Signal::Screen { state } => ("screen", state_field(*state)),
664            Signal::Hand { state } => ("hand", state_field(*state)),
665            Signal::Deaf { state } => ("deaf", state_field(*state)),
666            Signal::Speaking => ("speaking", literal_state_field("speaking")),
667            Signal::Silent => ("silent", literal_state_field("silent")),
668            Signal::React { member, emoji } => ("react", react_fields(member.as_deref(), emoji)),
669            Signal::Promote { target } => ("promote", target_fields(target)),
670            Signal::Demote { target } => ("demote", target_fields(target)),
671            Signal::Role { member, role } => ("role", role_fields(member, *role)),
672            Signal::Error { error } => ("error", error_fields(error.as_deref())),
673        }
674    }
675
676    fn from_value(value: &Json) -> Option<Self> {
677        let kind = value.field("type")?.as_str()?;
678        match kind {
679            "join" => Some(Signal::Join {
680                channel: field_string(value, "channel")?,
681            }),
682            "leave" => Some(Signal::Leave {
683                channel: field_string(value, "channel")?,
684            }),
685            "joined" => decode_joined(value),
686            "offer" => decode_offer(value),
687            "answer" => decode_answer(value),
688            "ice" => decode_ice(value),
689            "presence" => decode_presence(value),
690            "mic" => Some(Signal::Mic {
691                state: decode_on_off(value)?,
692            }),
693            "video" => Some(Signal::Video {
694                state: decode_on_off(value)?,
695            }),
696            "screen" => Some(Signal::Screen {
697                state: decode_on_off(value)?,
698            }),
699            "hand" => Some(Signal::Hand {
700                state: decode_on_off(value)?,
701            }),
702            "deaf" => Some(Signal::Deaf {
703                state: decode_on_off(value)?,
704            }),
705            "speaking" => Some(Signal::Speaking),
706            "silent" => Some(Signal::Silent),
707            "react" => decode_react(value),
708            "promote" => Some(Signal::Promote {
709                target: field_string(value, "target")?,
710            }),
711            "demote" => Some(Signal::Demote {
712                target: field_string(value, "target")?,
713            }),
714            "role" => decode_role(value),
715            "error" => Some(Signal::Error {
716                error: field_string_opt(value, "error"),
717            }),
718            _ => None,
719        }
720    }
721}
722
723fn channel_fields(channel: &str) -> Vec<(String, Json)> {
724    alloc::vec![("channel".to_string(), Json::Str(channel.to_string()))]
725}
726
727fn target_fields(target: &str) -> Vec<(String, Json)> {
728    alloc::vec![("target".to_string(), Json::Str(target.to_string()))]
729}
730
731fn state_field(state: OnOff) -> Vec<(String, Json)> {
732    alloc::vec![("state".to_string(), Json::Str(state.wire().to_string()))]
733}
734
735fn literal_state_field(state: &str) -> Vec<(String, Json)> {
736    alloc::vec![("state".to_string(), Json::Str(state.to_string()))]
737}
738
739fn role_fields(member: &str, role: Role) -> Vec<(String, Json)> {
740    alloc::vec![
741        ("member".to_string(), Json::Str(member.to_string())),
742        ("role".to_string(), Json::Str(role.wire().to_string())),
743    ]
744}
745
746fn react_fields(member: Option<&str>, emoji: &str) -> Vec<(String, Json)> {
747    let mut fields = Vec::new();
748    if let Some(member) = member {
749        fields.push(("member".to_string(), Json::Str(member.to_string())));
750    }
751    fields.push(("emoji".to_string(), Json::Str(emoji.to_string())));
752    fields
753}
754
755fn ice_fields(cand: &str, mid: Option<&str>, mlineidx: Option<u32>) -> Vec<(String, Json)> {
756    let mut fields = alloc::vec![("cand".to_string(), Json::Str(cand.to_string()))];
757    opt_field(&mut fields, "mid", mid.map(|m| Json::Str(m.to_string())));
758    opt_field(
759        &mut fields,
760        "mlineidx",
761        mlineidx.map(|n| Json::Num(i64::from(n))),
762    );
763    fields
764}
765
766fn presence_fields(
767    member: &str,
768    state: PresenceState,
769    kind: Option<ToggleKind>,
770    role: Option<Role>,
771) -> Vec<(String, Json)> {
772    let mut fields = alloc::vec![
773        ("member".to_string(), Json::Str(member.to_string())),
774        ("state".to_string(), Json::Str(state.wire().to_string())),
775    ];
776    opt_field(
777        &mut fields,
778        "kind",
779        kind.map(|k| Json::Str(k.wire().to_string())),
780    );
781    opt_field(
782        &mut fields,
783        "role",
784        role.map(|r| Json::Str(r.wire().to_string())),
785    );
786    fields
787}
788
789fn offer_fields(
790    sdp: &str,
791    tracks: Option<&[TrackHint]>,
792    chunk: Option<&ChunkMeta>,
793) -> Vec<(String, Json)> {
794    let mut fields = alloc::vec![("sdp".to_string(), Json::Str(sdp.to_string()))];
795    opt_field(
796        &mut fields,
797        "tracks",
798        tracks.map(|t| Json::Array(t.iter().map(TrackHint::to_json).collect())),
799    );
800    if let Some(chunk) = chunk {
801        fields.extend(chunk_meta_fields(chunk));
802    }
803    fields
804}
805
806fn answer_fields(sdp: &str, chunk: Option<&ChunkMeta>) -> Vec<(String, Json)> {
807    let mut fields = alloc::vec![("sdp".to_string(), Json::Str(sdp.to_string()))];
808    if let Some(chunk) = chunk {
809        fields.extend(chunk_meta_fields(chunk));
810    }
811    fields
812}
813
814fn joined_fields(
815    members: &[String],
816    mode: Option<RoomKind>,
817    role: Option<Role>,
818    streamers: Option<&[String]>,
819    turn: Option<&TurnCredentials>,
820    tracks: Option<&[TrackHint]>,
821) -> Vec<(String, Json)> {
822    let mut fields = alloc::vec![("members".to_string(), strings_json(members))];
823    opt_field(
824        &mut fields,
825        "mode",
826        mode.map(|m| Json::Str(m.wire().to_string())),
827    );
828    opt_field(
829        &mut fields,
830        "role",
831        role.map(|r| Json::Str(r.wire().to_string())),
832    );
833    opt_field(&mut fields, "streamers", streamers.map(strings_json));
834    opt_field(&mut fields, "turn", turn.map(TurnCredentials::to_json));
835    opt_field(
836        &mut fields,
837        "tracks",
838        tracks.map(|t| Json::Array(t.iter().map(TrackHint::to_json).collect())),
839    );
840    fields
841}
842
843fn error_fields(error: Option<&str>) -> Vec<(String, Json)> {
844    let mut fields = Vec::new();
845    opt_field(
846        &mut fields,
847        "error",
848        error.map(|e| Json::Str(e.to_string())),
849    );
850    fields
851}
852
853fn decode_on_off(value: &Json) -> Option<OnOff> {
854    OnOff::parse(&field_string(value, "state")?)
855}
856
857fn decode_joined(value: &Json) -> Option<Signal> {
858    let members = strings(value.field("members")?)?;
859    let mode = field_string_opt(value, "mode").and_then(|s| RoomKind::parse(&s));
860    let role = field_string_opt(value, "role").and_then(|s| Role::parse(&s));
861    let streamers = value.field("streamers").and_then(strings);
862    let turn = value.field("turn").and_then(TurnCredentials::from_json);
863    let tracks = value.field("tracks").and_then(decode_tracks);
864    Some(Signal::Joined {
865        members,
866        mode,
867        role,
868        streamers,
869        turn,
870        tracks,
871    })
872}
873
874fn decode_tracks(value: &Json) -> Option<Vec<TrackHint>> {
875    value.as_array()?.iter().map(TrackHint::from_json).collect()
876}
877
878fn decode_offer(value: &Json) -> Option<Signal> {
879    Some(Signal::Offer {
880        sdp: field_string(value, "sdp")?,
881        tracks: value.field("tracks").and_then(decode_tracks),
882        chunk: decode_chunk(value),
883    })
884}
885
886fn decode_answer(value: &Json) -> Option<Signal> {
887    Some(Signal::Answer {
888        sdp: field_string(value, "sdp")?,
889        chunk: decode_chunk(value),
890    })
891}
892
893fn decode_ice(value: &Json) -> Option<Signal> {
894    Some(Signal::Ice {
895        cand: field_string(value, "cand")?,
896        mid: field_string_opt(value, "mid"),
897        mlineidx: value.field("mlineidx").and_then(Json::as_u32),
898    })
899}
900
901fn decode_presence(value: &Json) -> Option<Signal> {
902    Some(Signal::Presence {
903        member: field_string(value, "member")?,
904        state: PresenceState::parse(&field_string(value, "state")?)?,
905        kind: field_string_opt(value, "kind").and_then(|s| ToggleKind::parse(&s)),
906        role: field_string_opt(value, "role").and_then(|s| Role::parse(&s)),
907    })
908}
909
910fn decode_react(value: &Json) -> Option<Signal> {
911    Some(Signal::React {
912        member: field_string_opt(value, "member"),
913        emoji: field_string(value, "emoji")?,
914    })
915}
916
917fn decode_role(value: &Json) -> Option<Signal> {
918    Some(Signal::Role {
919        member: field_string(value, "member")?,
920        role: Role::parse(&field_string(value, "role")?)?,
921    })
922}
923
924// ---------------------------------------------------------------------------------------------
925// SDP chunking: splitting an oversized offer/answer, and reassembling one from its chunks.
926// ---------------------------------------------------------------------------------------------
927
928/// Default per-chunk SDP budget, in bytes.
929///
930/// IRCv3 specifies a 4094-byte client tag-value ceiling, and ObbyIRCd raises the limit its relay
931/// actually carries to 8191. A host on a different
932/// ircd, or one that wants headroom for the JSON and tag-value escaping this module does not
933/// itself perform, should measure its own server's real budget and pass that instead of trusting
934/// either number blindly.
935pub const DEFAULT_CHUNK_BUDGET: usize = 8191;
936
937/// One numbered slice of a split `offer`/`answer` frame.
938#[derive(Debug, Clone, PartialEq, Eq)]
939#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
940pub struct SdpChunk {
941    /// This slice's correlation fields.
942    pub chunk: ChunkMeta,
943    /// The slice of the sdp this chunk carries.
944    pub sdp: String,
945}
946
947/// Split `sdp` into chunks of at most `budget` bytes each, sharing `id`.
948///
949/// Returns `None` when `sdp` already fits within `budget`, so the caller sends it as a plain
950/// unchunked `offer`/`answer` rather than a chunk of one.
951pub fn split_sdp(sdp: &str, id: &str, budget: usize) -> Option<Vec<SdpChunk>> {
952    if budget == 0 || sdp.len() <= budget {
953        return None;
954    }
955    let slices = char_chunks(sdp, budget)?;
956    let total = u32::try_from(slices.len()).unwrap_or(u32::MAX);
957    Some(
958        slices
959            .into_iter()
960            .enumerate()
961            .map(|(seq, slice)| SdpChunk {
962                chunk: ChunkMeta {
963                    id: id.to_string(),
964                    seq: u32::try_from(seq).unwrap_or(u32::MAX),
965                    total,
966                },
967                sdp: slice.to_string(),
968            })
969            .collect(),
970    )
971}
972
973/// Cut `s` into pieces of at most `budget` bytes each, never splitting a UTF-8 character.
974///
975/// A character wider than `budget` still gets a whole chunk to itself: rounding `end` down to
976/// the nearest boundary can walk it all the way back to `start`, and a chunk cannot be empty
977/// without stalling `start` forever, so that case rounds `end` up to the next boundary instead.
978fn char_chunks(s: &str, budget: usize) -> Option<Vec<&str>> {
979    let mut chunks = Vec::new();
980    let mut start = 0;
981    while start < s.len() {
982        let mut end = (start + budget).min(s.len());
983        while end > start && !s.is_char_boundary(end) {
984            end -= 1;
985        }
986        if end == start {
987            end = start + 1;
988            while end < s.len() && !s.is_char_boundary(end) {
989                end += 1;
990            }
991        }
992        chunks.push(s.get(start..end)?);
993        start = end;
994    }
995    Some(chunks)
996}
997
998/// How many chunks one in-flight reassembly may claim before it is refused outright.
999///
1000/// A peer naming an implausibly large `total` would otherwise have this buffer chunks forever
1001/// waiting for pieces that may never arrive.
1002pub const DEFAULT_MAX_CHUNKS_PER_REASSEMBLY: usize = 64;
1003
1004/// How many distinct chunked signals may be reassembling at once.
1005///
1006/// A peer opening an unbounded number of `id`s, each fed only a fraction of its chunks, would
1007/// otherwise grow this connection's memory without limit.
1008pub const DEFAULT_MAX_CONCURRENT_REASSEMBLIES: usize = 16;
1009
1010#[derive(Debug, Clone)]
1011#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1012struct PartialSdp {
1013    total: u32,
1014    parts: BTreeMap<u32, String>,
1015}
1016
1017/// A bounded buffer that reassembles `offer`/`answer` frames split across chunks.
1018#[derive(Debug, Clone)]
1019#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1020pub struct SdpReassembler {
1021    partials: BTreeMap<String, PartialSdp>,
1022    max_chunks: usize,
1023    max_concurrent: usize,
1024}
1025
1026impl SdpReassembler {
1027    /// A reassembler using the default bounds.
1028    pub fn new() -> Self {
1029        Self::with_bounds(
1030            DEFAULT_MAX_CHUNKS_PER_REASSEMBLY,
1031            DEFAULT_MAX_CONCURRENT_REASSEMBLIES,
1032        )
1033    }
1034
1035    /// A reassembler bounded to `max_chunks` per id, and `max_concurrent` ids in flight at once.
1036    pub fn with_bounds(max_chunks: usize, max_concurrent: usize) -> Self {
1037        Self {
1038            partials: BTreeMap::new(),
1039            max_chunks,
1040            max_concurrent,
1041        }
1042    }
1043
1044    /// Feed one chunk, returning the joined sdp once every piece named by `chunk.total` has
1045    /// arrived, in `seq` order regardless of the order they arrived in.
1046    pub fn push(&mut self, chunk: &ChunkMeta, sdp: &str) -> Option<String> {
1047        let total = usize::try_from(chunk.total).unwrap_or(usize::MAX);
1048        if chunk.total == 0 || total > self.max_chunks || chunk.seq >= chunk.total {
1049            return None;
1050        }
1051        if !self.partials.contains_key(&chunk.id) && self.partials.len() >= self.max_concurrent {
1052            return None;
1053        }
1054        let partial = self
1055            .partials
1056            .entry(chunk.id.clone())
1057            .or_insert_with(|| PartialSdp {
1058                total: chunk.total,
1059                parts: BTreeMap::new(),
1060            });
1061        if partial.total != chunk.total {
1062            return None;
1063        }
1064        partial.parts.insert(chunk.seq, sdp.to_string());
1065        if partial.parts.len() != total {
1066            return None;
1067        }
1068        let complete = self.partials.remove(&chunk.id)?;
1069        let mut joined = String::new();
1070        for seq in 0..complete.total {
1071            joined.push_str(complete.parts.get(&seq)?);
1072        }
1073        Some(joined)
1074    }
1075
1076    /// How many distinct ids are currently mid-reassembly.
1077    pub fn pending_len(&self) -> usize {
1078        self.partials.len()
1079    }
1080
1081    /// Discard every in-flight reassembly, for a connection that just dropped.
1082    pub fn drop_all(&mut self) {
1083        self.partials.clear();
1084    }
1085}
1086
1087impl Default for SdpReassembler {
1088    fn default() -> Self {
1089        Self::new()
1090    }
1091}
1092
1093// ---------------------------------------------------------------------------------------------
1094// Room state.
1095// ---------------------------------------------------------------------------------------------
1096
1097/// One participant's state within a [`Room`].
1098///
1099/// Every toggle is [`OnOff`] rather than `bool`: this is the same distinction clippy's own
1100/// `struct_excessive_bools` lint asks for (six independent flags read equally well as a state
1101/// machine's cases), and reusing `OnOff` rather than inventing six near-identical two-variant
1102/// enums keeps it to one type.
1103#[derive(Debug, Clone, PartialEq, Eq)]
1104#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1105#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
1106#[cfg_attr(feature = "ts", ts(rename = "VoiceParticipant"))]
1107pub struct Participant {
1108    /// Their nick as the server spells it, since the map that holds them is keyed by a fold that
1109    /// throws that spelling away.
1110    pub nick: String,
1111    /// Whether they may publish, decided by the room kind and, in a `$` room, whether the
1112    /// server named them a streamer.
1113    pub role: Role,
1114    /// Microphone on.
1115    pub mic: OnOff,
1116    /// Camera on.
1117    pub video: OnOff,
1118    /// The voice-activity flag the last `presence` reported.
1119    pub speaking: OnOff,
1120    /// Not receiving room audio.
1121    pub deaf: OnOff,
1122    /// Screen share active.
1123    pub screen: OnOff,
1124    /// Hand raised.
1125    pub hand: OnOff,
1126}
1127
1128impl Participant {
1129    /// Someone in a room with every toggle off, in the role a room hands out by default.
1130    pub fn new(nick: impl Into<String>) -> Self {
1131        Self {
1132            nick: nick.into(),
1133            ..Self::default()
1134        }
1135    }
1136}
1137
1138impl Default for Participant {
1139    fn default() -> Self {
1140        Self {
1141            nick: String::new(),
1142            role: Role::Viewer,
1143            mic: OnOff::Off,
1144            video: OnOff::Off,
1145            speaking: OnOff::Off,
1146            deaf: OnOff::Off,
1147            screen: OnOff::Off,
1148            hand: OnOff::Off,
1149        }
1150    }
1151}
1152
1153/// The state of one voice room: who is in it, their kind of channel, and every participant's
1154/// mic, video, speaking, deaf, screen and hand state and role.
1155#[derive(Debug, Clone, PartialEq, Eq)]
1156#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1157#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
1158#[cfg_attr(feature = "ts", ts(rename = "VoiceRoom"))]
1159pub struct Room {
1160    /// The channel this room's signalling is scoped to.
1161    pub channel: String,
1162    /// Whether every member publishes, or only the streamer and whoever they promote.
1163    pub kind: RoomKind,
1164    /// Every known participant, keyed by their folded nick.
1165    pub participants: BTreeMap<CaseFolded, Participant>,
1166    /// The TURN credentials the SFU handed us on `joined`, if it has yet.
1167    pub turn: Option<TurnCredentials>,
1168}
1169
1170impl Room {
1171    /// A room for `channel` with no participants yet, whose kind follows the `^`/`$` prefix.
1172    pub fn new(channel: impl Into<String>) -> Self {
1173        let channel = channel.into();
1174        let kind = RoomKind::for_channel(&channel);
1175        Self {
1176            channel,
1177            kind,
1178            participants: BTreeMap::new(),
1179            turn: None,
1180        }
1181    }
1182
1183    /// Apply one server signal, updating membership and per-participant state.
1184    ///
1185    /// `me` is our own nick, needed to resolve the local role a `joined` frame reports for us
1186    /// alone. `casemap` is the server's, because two spellings of one nick are one person here
1187    /// exactly as they are everywhere else. Every other frame type is ignored: they are either
1188    /// outbound intents this room waits to see echoed back as `presence`, or carry nothing about
1189    /// membership or state.
1190    pub fn apply(&mut self, me: &str, signal: &Signal, casemap: Casemapping) {
1191        match signal {
1192            Signal::Joined {
1193                members,
1194                role,
1195                streamers,
1196                turn,
1197                ..
1198            } => self.apply_joined(
1199                me,
1200                members,
1201                *role,
1202                streamers.as_deref(),
1203                turn.as_ref(),
1204                casemap,
1205            ),
1206            Signal::Presence {
1207                member,
1208                state,
1209                kind,
1210                role,
1211            } => self.apply_presence(member, *state, *kind, *role, casemap),
1212            Signal::Role { member, role } => {
1213                self.participant_mut(member, casemap).role = *role;
1214            }
1215            _ => {}
1216        }
1217    }
1218
1219    fn apply_joined(
1220        &mut self,
1221        me: &str,
1222        members: &[String],
1223        role: Option<Role>,
1224        streamers: Option<&[String]>,
1225        turn: Option<&TurnCredentials>,
1226        casemap: Casemapping,
1227    ) {
1228        self.turn = turn.cloned();
1229        for member in members {
1230            let assigned = match (casemap.eq(member, me), role) {
1231                (true, Some(role)) => role,
1232                _ => self.role_for(member, streamers, casemap),
1233            };
1234            self.participant_mut(member, casemap).role = assigned;
1235        }
1236    }
1237
1238    /// The participant record for a nick, created in the room's default role if it is new.
1239    fn participant_mut(&mut self, member: &str, casemap: Casemapping) -> &mut Participant {
1240        self.participants
1241            .entry(casemap.fold(member))
1242            .or_insert_with(|| Participant::new(member))
1243    }
1244
1245    fn apply_presence(
1246        &mut self,
1247        member: &str,
1248        state: PresenceState,
1249        kind: Option<ToggleKind>,
1250        role: Option<Role>,
1251        casemap: Casemapping,
1252    ) {
1253        match state {
1254            PresenceState::Joined => {
1255                let assigned = role.unwrap_or_else(|| self.role_for(member, None, casemap));
1256                self.participant_mut(member, casemap).role = assigned;
1257            }
1258            PresenceState::Left => {
1259                self.participants.remove(&casemap.fold(member));
1260            }
1261            PresenceState::On | PresenceState::Off => {
1262                if let (Some(kind), Some(participant)) =
1263                    (kind, self.participants.get_mut(&casemap.fold(member)))
1264                {
1265                    let toggled = if state == PresenceState::On {
1266                        OnOff::On
1267                    } else {
1268                        OnOff::Off
1269                    };
1270                    kind.apply(participant, toggled);
1271                }
1272            }
1273            PresenceState::Speaking => {
1274                self.set_flag(member, casemap, |p| p.speaking = OnOff::On);
1275            }
1276            PresenceState::Silent => self.set_flag(member, casemap, |p| p.speaking = OnOff::Off),
1277            PresenceState::DeafOn => self.set_flag(member, casemap, |p| p.deaf = OnOff::On),
1278            PresenceState::DeafOff => self.set_flag(member, casemap, |p| p.deaf = OnOff::Off),
1279        }
1280    }
1281
1282    fn set_flag(&mut self, member: &str, casemap: Casemapping, f: impl FnOnce(&mut Participant)) {
1283        if let Some(participant) = self.participants.get_mut(&casemap.fold(member)) {
1284            f(participant);
1285        }
1286    }
1287
1288    fn role_for(&self, member: &str, streamers: Option<&[String]>, casemap: Casemapping) -> Role {
1289        match self.kind {
1290            RoomKind::Publish => Role::Publisher,
1291            RoomKind::Stream => {
1292                let is_streamer =
1293                    streamers.is_some_and(|list| list.iter().any(|name| casemap.eq(name, member)));
1294                if is_streamer {
1295                    Role::Publisher
1296                } else {
1297                    Role::Viewer
1298                }
1299            }
1300        }
1301    }
1302}
1303
1304#[cfg(test)]
1305mod tests {
1306    use super::*;
1307
1308    fn turn() -> TurnCredentials {
1309        TurnCredentials {
1310            urls: alloc::vec!["turn:relay.obby.chat:3478".to_string()],
1311            username: "user1".to_string(),
1312            password: "pass1".to_string(),
1313        }
1314    }
1315
1316    #[test]
1317    fn join_and_leave_round_trip_with_only_a_channel() {
1318        let join = Signal::Join {
1319            channel: "#general".to_string(),
1320        };
1321        assert_eq!(join.to_json(), r##"{"type":"join","channel":"#general"}"##);
1322        assert_eq!(Signal::from_json(&join.to_json()), Some(join));
1323
1324        let leave = Signal::Leave {
1325            channel: "^voice".to_string(),
1326        };
1327        assert_eq!(leave.to_json(), r#"{"type":"leave","channel":"^voice"}"#);
1328        assert_eq!(Signal::from_json(&leave.to_json()), Some(leave));
1329    }
1330
1331    #[test]
1332    fn joined_round_trips_with_every_optional_field_present() {
1333        let joined = Signal::Joined {
1334            members: alloc::vec!["alice".to_string(), "bob".to_string()],
1335            mode: Some(RoomKind::Stream),
1336            role: Some(Role::Publisher),
1337            streamers: Some(alloc::vec!["alice".to_string()]),
1338            turn: Some(turn()),
1339            tracks: Some(alloc::vec![TrackHint {
1340                mid: "0".to_string(),
1341                member: "alice".to_string(),
1342            }]),
1343        };
1344        let json = joined.to_json();
1345        assert!(json.starts_with(r#"{"type":"joined","members":["alice","bob"]"#));
1346        assert!(json.contains(r#""mode":"stream""#));
1347        assert!(json.contains(r#""role":"streamer""#));
1348        assert!(json.contains(r#""streamers":["alice"]"#));
1349        assert!(json.contains(
1350            r#""turn":{"urls":["turn:relay.obby.chat:3478"],"username":"user1","password":"pass1"}"#
1351        ));
1352        assert!(json.contains(r#""tracks":[{"mid":"0","member":"alice"}]"#));
1353        assert_eq!(Signal::from_json(&json), Some(joined));
1354    }
1355
1356    #[test]
1357    fn joined_round_trips_with_every_optional_field_absent() {
1358        let joined = Signal::Joined {
1359            members: alloc::vec!["alice".to_string()],
1360            mode: None,
1361            role: None,
1362            streamers: None,
1363            turn: None,
1364            tracks: None,
1365        };
1366        assert_eq!(joined.to_json(), r#"{"type":"joined","members":["alice"]}"#);
1367        assert_eq!(Signal::from_json(&joined.to_json()), Some(joined));
1368    }
1369
1370    #[test]
1371    fn offer_and_answer_round_trip_unchunked() {
1372        let offer = Signal::Offer {
1373            sdp: "v=0\r\no=- 0 0 IN IP4 0.0.0.0\r\n".to_string(),
1374            tracks: None,
1375            chunk: None,
1376        };
1377        assert_eq!(Signal::from_json(&offer.to_json()), Some(offer.clone()));
1378        assert!(!offer.to_json().contains("\"id\""));
1379
1380        let answer = Signal::Answer {
1381            sdp: "v=0\r\n".to_string(),
1382            chunk: None,
1383        };
1384        assert_eq!(Signal::from_json(&answer.to_json()), Some(answer));
1385    }
1386
1387    #[test]
1388    fn offer_and_answer_round_trip_with_chunk_metadata() {
1389        let offer = Signal::Offer {
1390            sdp: "partial-sdp".to_string(),
1391            tracks: None,
1392            chunk: Some(ChunkMeta {
1393                id: "abc123".to_string(),
1394                seq: 1,
1395                total: 3,
1396            }),
1397        };
1398        let json = offer.to_json();
1399        assert_eq!(
1400            json,
1401            r#"{"type":"offer","sdp":"partial-sdp","id":"abc123","seq":1,"total":3}"#
1402        );
1403        assert_eq!(Signal::from_json(&json), Some(offer));
1404    }
1405
1406    #[test]
1407    fn ice_round_trips_with_and_without_mid() {
1408        let with_mid = Signal::Ice {
1409            cand: "candidate:1 1 UDP 1 1.2.3.4 5 typ host".to_string(),
1410            mid: Some("0".to_string()),
1411            mlineidx: Some(0),
1412        };
1413        assert_eq!(Signal::from_json(&with_mid.to_json()), Some(with_mid));
1414
1415        let without_mid = Signal::Ice {
1416            cand: "candidate:1 1 UDP 1 1.2.3.4 5 typ host".to_string(),
1417            mid: None,
1418            mlineidx: None,
1419        };
1420        assert_eq!(
1421            without_mid.to_json(),
1422            r#"{"type":"ice","cand":"candidate:1 1 UDP 1 1.2.3.4 5 typ host"}"#
1423        );
1424        assert_eq!(Signal::from_json(&without_mid.to_json()), Some(without_mid));
1425    }
1426
1427    #[test]
1428    fn presence_round_trips_all_three_shapes() {
1429        let membership = Signal::Presence {
1430            member: "bob".to_string(),
1431            state: PresenceState::Joined,
1432            kind: None,
1433            role: Some(Role::Viewer),
1434        };
1435        assert_eq!(
1436            membership.to_json(),
1437            r#"{"type":"presence","member":"bob","state":"joined","role":"viewer"}"#
1438        );
1439        assert_eq!(Signal::from_json(&membership.to_json()), Some(membership));
1440
1441        let toggle = Signal::Presence {
1442            member: "bob".to_string(),
1443            state: PresenceState::On,
1444            kind: Some(ToggleKind::Mic),
1445            role: None,
1446        };
1447        assert_eq!(
1448            toggle.to_json(),
1449            r#"{"type":"presence","member":"bob","state":"on","kind":"mic"}"#
1450        );
1451        assert_eq!(Signal::from_json(&toggle.to_json()), Some(toggle));
1452
1453        let activity = Signal::Presence {
1454            member: "bob".to_string(),
1455            state: PresenceState::DeafOn,
1456            kind: None,
1457            role: None,
1458        };
1459        assert_eq!(
1460            activity.to_json(),
1461            r#"{"type":"presence","member":"bob","state":"deaf-on"}"#
1462        );
1463        assert_eq!(Signal::from_json(&activity.to_json()), Some(activity));
1464    }
1465
1466    #[test]
1467    fn the_six_toggle_intents_round_trip() {
1468        let frames = [
1469            Signal::Mic { state: OnOff::On },
1470            Signal::Video { state: OnOff::Off },
1471            Signal::Screen { state: OnOff::On },
1472            Signal::Hand { state: OnOff::On },
1473            Signal::Speaking,
1474            Signal::Silent,
1475        ];
1476        for frame in frames {
1477            assert_eq!(Signal::from_json(&frame.to_json()), Some(frame));
1478        }
1479    }
1480
1481    #[test]
1482    fn deaf_is_a_real_outbound_frame_not_only_a_local_flag() {
1483        let deafen = Signal::Deaf { state: OnOff::On };
1484        assert_eq!(deafen.to_json(), r#"{"type":"deaf","state":"on"}"#);
1485        assert_eq!(Signal::from_json(&deafen.to_json()), Some(deafen));
1486    }
1487
1488    #[test]
1489    fn react_round_trips_outbound_and_inbound_shapes() {
1490        let outbound = Signal::React {
1491            member: None,
1492            emoji: "👍".to_string(),
1493        };
1494        assert_eq!(outbound.to_json(), r#"{"type":"react","emoji":"👍"}"#);
1495        assert_eq!(Signal::from_json(&outbound.to_json()), Some(outbound));
1496
1497        let inbound = Signal::React {
1498            member: Some("carol".to_string()),
1499            emoji: "👍".to_string(),
1500        };
1501        assert_eq!(
1502            inbound.to_json(),
1503            r#"{"type":"react","member":"carol","emoji":"👍"}"#
1504        );
1505        assert_eq!(Signal::from_json(&inbound.to_json()), Some(inbound));
1506    }
1507
1508    #[test]
1509    fn promote_demote_and_role_round_trip() {
1510        let promote = Signal::Promote {
1511            target: "bob".to_string(),
1512        };
1513        assert_eq!(Signal::from_json(&promote.to_json()), Some(promote));
1514
1515        let demote = Signal::Demote {
1516            target: "bob".to_string(),
1517        };
1518        assert_eq!(Signal::from_json(&demote.to_json()), Some(demote));
1519
1520        let role = Signal::Role {
1521            member: "bob".to_string(),
1522            role: Role::Publisher,
1523        };
1524        assert_eq!(
1525            role.to_json(),
1526            r#"{"type":"role","member":"bob","role":"streamer"}"#
1527        );
1528        assert_eq!(Signal::from_json(&role.to_json()), Some(role));
1529    }
1530
1531    #[test]
1532    fn error_round_trips_with_and_without_a_reason() {
1533        let with_reason = Signal::Error {
1534            error: Some("room is full".to_string()),
1535        };
1536        assert_eq!(
1537            with_reason.to_json(),
1538            r#"{"type":"error","error":"room is full"}"#
1539        );
1540        assert_eq!(Signal::from_json(&with_reason.to_json()), Some(with_reason));
1541
1542        let without_reason = Signal::Error { error: None };
1543        assert_eq!(without_reason.to_json(), r#"{"type":"error"}"#);
1544        assert_eq!(
1545            Signal::from_json(&without_reason.to_json()),
1546            Some(without_reason)
1547        );
1548    }
1549
1550    #[test]
1551    fn unknown_frame_types_decode_to_none() {
1552        assert_eq!(Signal::from_json(r#"{"type":"nonsense"}"#), None);
1553    }
1554
1555    #[test]
1556    fn malformed_json_decodes_to_none() {
1557        assert_eq!(Signal::from_json("not json"), None);
1558        assert_eq!(Signal::from_json(r#"{"type":"join""#), None);
1559        assert_eq!(Signal::from_json(r##"{"channel":"#general"}"##), None);
1560    }
1561
1562    #[test]
1563    fn an_sdp_within_budget_is_not_split() {
1564        assert_eq!(split_sdp("short sdp", "id1", 8191), None);
1565    }
1566
1567    #[test]
1568    fn an_oversized_sdp_is_split_into_numbered_chunks_sharing_one_id() {
1569        let sdp = "abcdefghij";
1570        let chunks = split_sdp(sdp, "call-1", 3).expect("must split");
1571        assert_eq!(chunks.len(), 4);
1572        for (seq, chunk) in chunks.iter().enumerate() {
1573            assert_eq!(chunk.chunk.id, "call-1");
1574            assert_eq!(chunk.chunk.seq, u32::try_from(seq).unwrap_or(u32::MAX));
1575            assert_eq!(chunk.chunk.total, 4);
1576        }
1577        let joined: String = chunks.iter().map(|c| c.sdp.as_str()).collect();
1578        assert_eq!(joined, sdp);
1579    }
1580
1581    #[test]
1582    fn a_multibyte_character_is_never_split_across_a_chunk_boundary() {
1583        let sdp = "a👍b";
1584        let chunks = split_sdp(sdp, "id", 2).expect("must split");
1585        for chunk in &chunks {
1586            assert!(core::str::from_utf8(chunk.sdp.as_bytes()).is_ok());
1587        }
1588        let joined: String = chunks.iter().map(|c| c.sdp.as_str()).collect();
1589        assert_eq!(joined, sdp);
1590    }
1591
1592    #[test]
1593    fn chunks_reassemble_regardless_of_arrival_order() {
1594        let chunks = split_sdp(&"x".repeat(20), "call-1", 6).expect("must split");
1595        let mut reassembler = SdpReassembler::new();
1596        let mut out = None;
1597        for chunk in chunks.iter().rev() {
1598            out = reassembler.push(&chunk.chunk, &chunk.sdp);
1599        }
1600        assert_eq!(out, Some("x".repeat(20)));
1601        assert_eq!(reassembler.pending_len(), 0);
1602    }
1603
1604    #[test]
1605    fn a_reassembly_missing_a_chunk_never_completes() {
1606        let chunks = split_sdp(&"y".repeat(20), "call-2", 6).expect("must split");
1607        let mut reassembler = SdpReassembler::new();
1608        for chunk in chunks.iter().take(chunks.len() - 1) {
1609            assert_eq!(reassembler.push(&chunk.chunk, &chunk.sdp), None);
1610        }
1611        assert_eq!(reassembler.pending_len(), 1);
1612    }
1613
1614    #[test]
1615    fn reassembly_rejects_a_total_over_the_configured_bound() {
1616        let mut reassembler = SdpReassembler::with_bounds(4, 16);
1617        let chunk = ChunkMeta {
1618            id: "huge".to_string(),
1619            seq: 0,
1620            total: 5,
1621        };
1622        assert_eq!(reassembler.push(&chunk, "slice"), None);
1623        assert_eq!(reassembler.pending_len(), 0);
1624    }
1625
1626    #[test]
1627    fn reassembly_bounds_the_number_of_concurrent_ids() {
1628        let mut reassembler = SdpReassembler::with_bounds(64, 2);
1629        for id in ["a", "b"] {
1630            let chunk = ChunkMeta {
1631                id: id.to_string(),
1632                seq: 0,
1633                total: 2,
1634            };
1635            assert_eq!(reassembler.push(&chunk, "part"), None);
1636        }
1637        assert_eq!(reassembler.pending_len(), 2);
1638
1639        let overflow = ChunkMeta {
1640            id: "c".to_string(),
1641            seq: 0,
1642            total: 2,
1643        };
1644        assert_eq!(reassembler.push(&overflow, "part"), None);
1645        assert_eq!(
1646            reassembler.pending_len(),
1647            2,
1648            "a third id must not grow past the configured bound"
1649        );
1650    }
1651
1652    #[test]
1653    fn dropping_all_forgets_every_partial_reassembly() {
1654        let mut reassembler = SdpReassembler::new();
1655        let chunk = ChunkMeta {
1656            id: "call".to_string(),
1657            seq: 0,
1658            total: 2,
1659        };
1660        reassembler.push(&chunk, "part");
1661        reassembler.drop_all();
1662        assert_eq!(reassembler.pending_len(), 0);
1663    }
1664
1665    #[test]
1666    fn every_member_publishes_in_a_publish_room() {
1667        let mut room = Room::new("^voice");
1668        assert_eq!(room.kind, RoomKind::Publish);
1669        room.apply(
1670            "me",
1671            &Signal::Joined {
1672                members: alloc::vec!["me".to_string(), "bob".to_string()],
1673                mode: None,
1674                role: None,
1675                streamers: None,
1676                turn: None,
1677                tracks: None,
1678            },
1679            Casemapping::Rfc1459,
1680        );
1681        for nick in ["me", "bob"] {
1682            assert_eq!(
1683                room.participants[&CaseFolded::from(nick)].role,
1684                Role::Publisher
1685            );
1686        }
1687    }
1688
1689    #[test]
1690    fn only_the_streamers_publish_in_a_stream_room() {
1691        let mut room = Room::new("$live");
1692        assert_eq!(room.kind, RoomKind::Stream);
1693        room.apply(
1694            "me",
1695            &Signal::Joined {
1696                members: alloc::vec!["alice".to_string(), "me".to_string()],
1697                mode: Some(RoomKind::Stream),
1698                role: Some(Role::Viewer),
1699                streamers: Some(alloc::vec!["alice".to_string()]),
1700                turn: None,
1701                tracks: None,
1702            },
1703            Casemapping::Rfc1459,
1704        );
1705        assert_eq!(
1706            room.participants[&CaseFolded::from("alice")].role,
1707            Role::Publisher
1708        );
1709        assert_eq!(
1710            room.participants[&CaseFolded::from("me")].role,
1711            Role::Viewer
1712        );
1713    }
1714
1715    #[test]
1716    fn a_promote_broadcast_updates_the_targets_role() {
1717        let mut room = Room::new("$live");
1718        room.apply(
1719            "me",
1720            &Signal::Role {
1721                member: "bob".to_string(),
1722                role: Role::Publisher,
1723            },
1724            Casemapping::Rfc1459,
1725        );
1726        assert_eq!(
1727            room.participants[&CaseFolded::from("bob")].role,
1728            Role::Publisher
1729        );
1730    }
1731
1732    #[test]
1733    fn presence_toggles_update_the_matching_participant_flag() {
1734        let mut room = Room::new("^voice");
1735        room.participants
1736            .insert(CaseFolded::from("bob"), Participant::new("bob"));
1737        room.apply(
1738            "me",
1739            &Signal::Presence {
1740                member: "bob".to_string(),
1741                state: PresenceState::On,
1742                kind: Some(ToggleKind::Video),
1743                role: None,
1744            },
1745            Casemapping::Rfc1459,
1746        );
1747        assert_eq!(room.participants[&CaseFolded::from("bob")].video, OnOff::On);
1748        assert_eq!(room.participants[&CaseFolded::from("bob")].mic, OnOff::Off);
1749    }
1750
1751    #[test]
1752    fn one_participant_under_two_spellings_is_one_person() {
1753        let mut room = Room::new("^voice");
1754        room.apply(
1755            "me",
1756            &Signal::Presence {
1757                member: "Bob[dev]".to_string(),
1758                state: PresenceState::Joined,
1759                kind: None,
1760                role: None,
1761            },
1762            Casemapping::Rfc1459,
1763        );
1764        room.apply(
1765            "me",
1766            &Signal::Presence {
1767                member: "bob{dev}".to_string(),
1768                state: PresenceState::On,
1769                kind: Some(ToggleKind::Mic),
1770                role: None,
1771            },
1772            Casemapping::Rfc1459,
1773        );
1774        assert_eq!(room.participants.len(), 1);
1775        let participant = room
1776            .participants
1777            .values()
1778            .next()
1779            .expect("the room has one participant");
1780        assert_eq!(participant.nick, "Bob[dev]");
1781        assert_eq!(participant.mic, OnOff::On);
1782    }
1783
1784    #[test]
1785    fn presence_joined_and_left_add_and_remove_participants() {
1786        let mut room = Room::new("^voice");
1787        room.apply(
1788            "me",
1789            &Signal::Presence {
1790                member: "bob".to_string(),
1791                state: PresenceState::Joined,
1792                kind: None,
1793                role: None,
1794            },
1795            Casemapping::Rfc1459,
1796        );
1797        assert!(room.participants.contains_key(&CaseFolded::from("bob")));
1798
1799        room.apply(
1800            "me",
1801            &Signal::Presence {
1802                member: "bob".to_string(),
1803                state: PresenceState::Left,
1804                kind: None,
1805                role: None,
1806            },
1807            Casemapping::Rfc1459,
1808        );
1809        assert!(!room.participants.contains_key(&CaseFolded::from("bob")));
1810    }
1811
1812    #[test]
1813    fn deafen_broadcasts_to_the_room_instead_of_staying_local() {
1814        let mut room = Room::new("^voice");
1815        room.participants
1816            .insert(CaseFolded::from("bob"), Participant::new("bob"));
1817        room.apply(
1818            "me",
1819            &Signal::Presence {
1820                member: "bob".to_string(),
1821                state: PresenceState::DeafOn,
1822                kind: None,
1823                role: None,
1824            },
1825            Casemapping::Rfc1459,
1826        );
1827        assert_eq!(
1828            room.participants[&CaseFolded::from("bob")].deaf,
1829            OnOff::On,
1830            "the room's model must learn a peer's deafen from presence, the way it learns mic or video"
1831        );
1832
1833        let intent = Signal::Deaf { state: OnOff::On };
1834        assert!(
1835            intent.to_json().contains("\"type\":\"deaf\""),
1836            "deafening must produce a real outbound frame, not only flip a local field"
1837        );
1838    }
1839}