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