Skip to main content

o_sfu_model/
lib.rs

1//! shared application model for the Odoo Discuss SFU contract
2//!
3//! this crate defines the Odoo Discuss call concepts that multiple `o-sfu`
4//! crates must interpret identically
5//! they are more specific than RFC vocabulary but less specific than any one
6//! runtime subsystem
7//!
8//! the model crate depends only on serialization support
9//! sockets, async work, media transports, router topology, metrics registries,
10//! server configuration and JSON envelope parsing stay in the runtime, core,
11//! router, telemetry and protocol crates
12//!
13//! # Compatibility
14//!
15//! several types preserve the old SFU and Odoo browser contract
16//! they should
17//! remain small data types with explicit serde shapes and local normalization
18//! helpers
19//! runtime callers should normalize compatibility input at ingress before
20//! storing it in room state, diagnostics indexes or subscription maps
21
22use std::borrow::Cow;
23
24use serde::{Deserialize, Serialize};
25use serde_json::Value;
26
27/// opaque compatibility payload carried through legacy broadcast paths
28///
29/// prefer explicit application structs for new flows
30/// this alias exists where Odoo owns the shape and the SFU only relays the JSON
31/// value
32pub type JsonPayload = Value;
33
34/// user identity as accepted by the Odoo-facing call contract
35///
36/// Odoo normally uses integer user ids, while legacy and test callers may send
37/// string ids
38/// the runtime canonicalizes numeric strings before indexing room
39/// state so `"42"` and `42` cannot become two live users in the same call
40///
41/// non-numeric strings remain valid compatibility ids
42#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
43#[serde(untagged)]
44pub enum UserId {
45    Integer(i64),
46    String(String),
47}
48
49impl From<i64> for UserId {
50    fn from(value: i64) -> Self {
51        Self::Integer(value)
52    }
53}
54
55impl From<&str> for UserId {
56    fn from(value: &str) -> Self {
57        Self::String(value.to_owned())
58    }
59}
60
61impl From<String> for UserId {
62    fn from(value: String) -> Self {
63        Self::String(value)
64    }
65}
66
67impl UserId {
68    /// return the path representation used by diagnostics and bundle keys
69    /// string ids retain their raw representation
70    #[must_use]
71    pub fn path_segment(&self) -> Cow<'_, str> {
72        match self {
73            Self::Integer(value) => Cow::Owned(value.to_string()),
74            Self::String(value) => Cow::Borrowed(value),
75        }
76    }
77
78    /// return the runtime key form for this user id
79    ///
80    /// numeric strings are parsed into [`Self::Integer`] so all room state,
81    /// diagnostics lookup, disconnect handling and subscription logic use one
82    /// canonical key
83    /// non-numeric strings are preserved as compatibility identities
84    #[must_use]
85    pub fn normalized_for_runtime(self) -> Self {
86        match self {
87            Self::String(value) => value
88                .parse::<i64>()
89                .map_or(Self::String(value), Self::Integer),
90            Self::Integer(value) => Self::Integer(value),
91        }
92    }
93
94    /// borrowing variant of [`Self::normalized_for_runtime`]
95    ///
96    /// use this when the caller owns a borrowed auth or protocol payload and
97    /// needs the canonical runtime key without consuming that payload
98    #[must_use]
99    pub fn runtime_normalized(&self) -> Self {
100        self.clone().normalized_for_runtime()
101    }
102}
103
104/// room capabilities advertised to a newly connected browser client
105///
106/// these are call capabilities, not permission checks
107/// the room advertises
108/// which features exist for the call, then per-user permissions decide who may
109/// actually start or change a restricted feature
110#[allow(
111    clippy::struct_excessive_bools,
112    reason = "feature flags mirror the compatibility startup surface with explicit optional room capabilities"
113)]
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "camelCase")]
116pub struct AvailableFeatures {
117    /// `false` keeps compatibility with websocket-relay rooms
118    pub rtc: bool,
119    pub transcription: bool,
120    pub audio_recording: bool,
121    pub video_recording: bool,
122}
123
124/// current room recording state as shown to call participants
125///
126/// fields are optional because the compatibility surface may carry sparse
127/// updates
128/// room snapshots should fill known fields
129/// consumers must treat a missing field as "not asserted by this payload"
130#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct RecordingState {
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub recording: Option<bool>,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub audio: Option<bool>,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub transcription: Option<bool>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub video: Option<bool>,
141}
142
143/// business reason attached to a recording stop update
144///
145/// this code is shown to clients and diagnostics as the reason recording became
146/// inactive
147/// it does not describe transport failures or upload service details
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149pub enum StopCode {
150    #[serde(rename = "user_request")]
151    UserRequest,
152    #[serde(rename = "channel_closed")]
153    ChannelClosed,
154    #[serde(rename = "recording_timeout")]
155    RecordingTimeout,
156    #[serde(rename = "recording_failed")]
157    RecordingFailed,
158    #[serde(rename = "disk_space_exhausted")]
159    DiskSpaceExhausted,
160}
161
162/// recording state update emitted to clients and observers
163///
164/// `stop_code` is present only when the update explains why a recording session
165/// stopped
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167pub struct RecordingStateUpdate {
168    pub state: RecordingState,
169    /// present only when a recording became inactive
170    #[serde(rename = "stopCode", skip_serializing_if = "Option::is_none")]
171    pub stop_code: Option<StopCode>,
172}
173
174/// user-level permissions supplied by the Odoo authentication path
175///
176/// missing values are denied by the room runtime so omitted permissions never
177/// grant access by accident
178#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(rename_all = "camelCase")]
180pub struct UserPermissions {
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub transcription: Option<bool>,
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub audio_recording: Option<bool>,
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub video_recording: Option<bool>,
187}
188
189/// presence and call UI state associated with one room participant
190///
191/// this is participant state visible to other clients
192/// it does not include media routing, transport health or source identity
193///
194/// fields are optional so callers can send partial updates
195/// use [`Self::snapshot_complete`] when serializing a full room snapshot
196#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(rename_all = "camelCase")]
198pub struct UserInfo {
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub is_talking: Option<bool>,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub is_featured: Option<bool>,
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub is_camera_on: Option<bool>,
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub is_screen_sharing_on: Option<bool>,
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub is_self_muted: Option<bool>,
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub is_deaf: Option<bool>,
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub is_raising_hand: Option<bool>,
213}
214
215impl UserInfo {
216    /// fill missing presence fields with `false` for snapshot emission
217    ///
218    /// partial updates keep `None` to mean "unchanged"
219    /// full room snapshots use this so receivers can render without merging
220    /// against stale local data
221    #[must_use]
222    pub fn snapshot_complete(self) -> Self {
223        Self {
224            is_talking: Some(self.is_talking.unwrap_or(false)),
225            is_featured: Some(self.is_featured.unwrap_or(false)),
226            is_camera_on: Some(self.is_camera_on.unwrap_or(false)),
227            is_screen_sharing_on: Some(self.is_screen_sharing_on.unwrap_or(false)),
228            is_self_muted: Some(self.is_self_muted.unwrap_or(false)),
229            is_deaf: Some(self.is_deaf.unwrap_or(false)),
230            is_raising_hand: Some(self.is_raising_hand.unwrap_or(false)),
231        }
232    }
233
234    /// merge a partial presence update into the current stored value
235    ///
236    /// `None` means "unchanged", matching the wire contract for incremental
237    /// user-info updates
238    pub fn apply_partial_update(&mut self, update: &Self) {
239        if let Some(is_talking) = update.is_talking {
240            self.is_talking = Some(is_talking);
241        }
242        if let Some(is_featured) = update.is_featured {
243            self.is_featured = Some(is_featured);
244        }
245        if let Some(is_camera_on) = update.is_camera_on {
246            self.is_camera_on = Some(is_camera_on);
247        }
248        if let Some(is_screen_sharing_on) = update.is_screen_sharing_on {
249            self.is_screen_sharing_on = Some(is_screen_sharing_on);
250        }
251        if let Some(is_self_muted) = update.is_self_muted {
252            self.is_self_muted = Some(is_self_muted);
253        }
254        if let Some(is_deaf) = update.is_deaf {
255            self.is_deaf = Some(is_deaf);
256        }
257        if let Some(is_raising_hand) = update.is_raising_hand {
258            self.is_raising_hand = Some(is_raising_hand);
259        }
260    }
261
262    /// return this presence payload with the room-layout featured flag applied
263    #[must_use]
264    pub fn with_featured(mut self, is_featured: Option<bool>) -> Self {
265        self.is_featured = is_featured;
266        self
267    }
268}
269
270/// full peer entry sent when a client needs the current room membership view
271///
272/// the serialized `sessionId` field is the Odoo-facing user identity
273/// runtime connection ids are absent because reconnection and replacement are
274/// server-local concerns
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
276pub struct PeerSnapshot {
277    #[serde(rename = "sessionId")]
278    pub user_id: UserId,
279    #[serde(default)]
280    pub info: UserInfo,
281}
282
283/// receiver intent for which streams to download from one peer
284///
285/// this is client intent, not a transport subscription object
286/// missing fields mean the current receiver preference for that stream or
287/// layout should be left unchanged
288#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
289pub struct DownloadStates {
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub audio: Option<bool>,
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub camera: Option<bool>,
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub screen: Option<bool>,
296    #[serde(rename = "cameraLayout", skip_serializing_if = "Option::is_none")]
297    pub camera_layout: Option<VideoLayoutIntent>,
298    #[serde(rename = "screenLayout", skip_serializing_if = "Option::is_none")]
299    pub screen_layout: Option<VideoLayoutIntent>,
300}
301
302impl DownloadStates {
303    pub fn apply_partial_update(&mut self, update: &Self) {
304        *self = Self {
305            audio: update.audio.or(self.audio),
306            camera: update.camera.or(self.camera),
307            screen: update.screen.or(self.screen),
308            camera_layout: update.camera_layout.or(self.camera_layout),
309            screen_layout: update.screen_layout.or(self.screen_layout),
310        };
311    }
312}
313
314/// receiver-side layout role for a video stream
315///
316/// the room uses this layout hint to prioritize selected video layers under
317/// bandwidth pressure
318/// it does not name an RTP encoding, simulcast RID or concrete packet gate
319#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
320#[serde(rename_all = "snake_case")]
321pub enum VideoLayoutIntent {
322    /// main speaker or call focus
323    Featured,
324    /// user-pinned stream protected more strongly than ordinary thumbnails
325    Pinned,
326    /// thumbnail that is currently visible in the client layout
327    VisibleThumbnail,
328    /// stream hidden by the client layout
329    Hidden,
330    /// stream outside the currently visible layout range
331    ///
332    /// currently the same as hidden
333    /// the distinct value leaves room for more granular client layout policy
334    Overflow,
335}
336
337/// stream category exposed to Odoo clients
338///
339/// this is smaller than the internal source model
340/// the source model may contain encodings, RTP metadata and transport-local
341/// media ids, while `StreamType` only names the user-facing stream
342#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
343pub enum StreamType {
344    #[serde(rename = "audio")]
345    Audio,
346    #[serde(rename = "camera")]
347    Camera,
348    #[serde(rename = "screen")]
349    Screen,
350}
351
352/// recording modes requested by a user
353///
354/// missing fields mean the caller did not request that mode
355/// the room combines these options with feature flags, current recording state
356/// and [`UserPermissions`] before mutating recording state
357#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
358pub struct RecordingOptions {
359    #[serde(skip_serializing_if = "Option::is_none")]
360    pub audio: Option<bool>,
361    #[serde(skip_serializing_if = "Option::is_none")]
362    pub transcription: Option<bool>,
363    #[serde(skip_serializing_if = "Option::is_none")]
364    pub video: Option<bool>,
365}
366
367/// websocket close code vocabulary shared by server and browser protocol code
368///
369/// standard codes keep their RFC meaning
370/// custom codes mirror the legacy Odoo SFU websocket close vocabulary used by
371/// browser clients and low-cardinality telemetry
372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
373#[repr(u16)]
374pub enum WebSocketCloseCode {
375    /// normal websocket closure
376    Clean = 1000,
377    /// the peer is leaving
378    Leaving = 1001,
379    /// the peer sent a malformed or invalid protocol message
380    ProtocolError = 1002,
381    /// the server hit an internal error while handling the socket
382    Error = 1011,
383
384    /// authentication failed
385    AuthFailed = 4106,
386    /// the client did not authenticate before the server timeout
387    AuthTimeout = 4107,
388    /// the runtime removed this client from the room
389    Kicked = 4108,
390    /// admission failed because the room cannot accept another user
391    RoomFull = 4109,
392}
393
394impl WebSocketCloseCode {
395    /// decode a raw websocket close code if it belongs to the shared vocabulary
396    ///
397    /// unknown codes return `None` so the caller can keep foreign websocket
398    /// close reasons out of application telemetry labels and protocol state
399    /// machines
400    #[must_use]
401    pub const fn from_u16(value: u16) -> Option<Self> {
402        match value {
403            1000 => Some(Self::Clean),
404            1001 => Some(Self::Leaving),
405            1002 => Some(Self::ProtocolError),
406            1011 => Some(Self::Error),
407            4106 => Some(Self::AuthFailed),
408            4107 => Some(Self::AuthTimeout),
409            4108 => Some(Self::Kicked),
410            4109 => Some(Self::RoomFull),
411            _ => None,
412        }
413    }
414}
415
416impl From<WebSocketCloseCode> for u16 {
417    fn from(value: WebSocketCloseCode) -> Self {
418        match value {
419            WebSocketCloseCode::Clean => 1000,
420            WebSocketCloseCode::Leaving => 1001,
421            WebSocketCloseCode::ProtocolError => 1002,
422            WebSocketCloseCode::Error => 1011,
423            WebSocketCloseCode::AuthFailed => 4106,
424            WebSocketCloseCode::AuthTimeout => 4107,
425            WebSocketCloseCode::Kicked => 4108,
426            WebSocketCloseCode::RoomFull => 4109,
427        }
428    }
429}
430
431#[cfg(test)]
432#[path = "TESTS/lib.rs"]
433mod tests;