1use std::sync::Arc;
4
5use futures_util::Stream;
6use reqwest::Method;
7use serde::{Deserialize, Serialize};
8use serde_json::{Map, Value};
9
10use crate::client::{CallOptions, Client};
11use crate::common::{string_enum, ResourceLinks};
12use crate::error::{Error, Result};
13use crate::pagination::{auto_page, paginate, ListParams, Page, PageFetcher};
14use crate::query::QueryBuilder;
15use crate::resources::escape;
16use crate::resources::session_utils::reshape_session_participants;
17
18const PATH: &str = "/v2/sessions";
19
20string_enum! {
21 SessionStatus {
27 ONGOING => "ongoing",
29 ENDED => "ended",
31 }
32}
33
34pub type QualityStats = Value;
38
39#[derive(Debug, Clone, Deserialize)]
41pub struct ParticipantTimelog {
42 #[serde(default, deserialize_with = "crate::common::null_to_default")]
44 pub start: String,
45 pub end: Option<String>,
47}
48
49#[derive(Debug, Clone, Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub struct Participant {
53 pub participant_id: String,
55 #[serde(default, deserialize_with = "crate::common::null_to_default")]
57 pub name: String,
58 #[serde(default, deserialize_with = "crate::common::null_to_default")]
60 pub timelog: Vec<ParticipantTimelog>,
61 #[serde(flatten)]
63 pub extra: Map<String, Value>,
64}
65
66#[derive(Debug, Clone, Deserialize)]
68#[serde(rename_all = "camelCase")]
69pub struct Session {
70 pub id: String,
72 #[serde(default, deserialize_with = "crate::common::null_to_default")]
77 pub room_id: String,
78 pub custom_room_id: Option<String>,
80 #[serde(default, deserialize_with = "crate::common::null_to_default")]
82 pub start: String,
83 pub end: Option<String>,
85 pub status: Option<SessionStatus>,
87 #[serde(default, deserialize_with = "crate::common::null_to_default")]
89 pub participants: Vec<Participant>,
90 #[serde(default, deserialize_with = "crate::common::null_to_default")]
92 pub region: String,
93 #[serde(default, deserialize_with = "crate::common::null_to_default")]
95 pub links: ResourceLinks,
96 #[serde(flatten)]
98 pub extra: Map<String, Value>,
99}
100
101#[derive(Debug, Clone, Default)]
103pub struct SessionListParams {
104 pub page: Option<u32>,
106 pub per_page: Option<u32>,
108 pub cursor: Option<String>,
110 pub query: Option<String>,
112 pub room_id: Option<String>,
114 pub custom_room_id: Option<String>,
116 pub user_id: Option<String>,
118 pub start_date: Option<i64>,
120 pub end_date: Option<i64>,
122 pub status: Option<SessionStatus>,
124}
125
126impl SessionListParams {
127 fn pagination(&self) -> ListParams {
128 ListParams {
129 page: self.page,
130 per_page: self.per_page,
131 cursor: self.cursor.clone(),
132 }
133 }
134}
135
136#[derive(Debug, Clone, Default)]
138pub struct SessionEndParams {
139 pub room_id: Option<String>,
141 pub meeting_id: Option<String>,
143 pub session_id: Option<String>,
145 pub force: Option<bool>,
147 pub ignore_closed: Option<bool>,
149}
150
151#[derive(Debug, Serialize)]
152#[serde(rename_all = "camelCase")]
153struct SessionEndWire<'a> {
154 room_id: &'a str,
155 #[serde(skip_serializing_if = "Option::is_none")]
156 session_id: Option<&'a str>,
157 #[serde(skip_serializing_if = "Option::is_none")]
158 ignore_closed: Option<bool>,
159 #[serde(rename = "vsdkForceKill", skip_serializing_if = "Option::is_none")]
160 force: Option<bool>,
161}
162
163#[derive(Debug, Clone, Default)]
165pub struct SessionRemoveParticipantParams {
166 pub participant_id: String,
168 pub room_id: Option<String>,
170 pub session_id: Option<String>,
172}
173
174#[derive(Debug, Serialize)]
175#[serde(rename_all = "camelCase")]
176struct RemoveParticipantWire<'a> {
177 participant_id: &'a str,
178 #[serde(skip_serializing_if = "Option::is_none")]
179 room_id: Option<&'a str>,
180 #[serde(skip_serializing_if = "Option::is_none")]
181 session_id: Option<&'a str>,
182}
183
184#[derive(Debug, Clone, Copy)]
186pub struct SessionsResource<'a> {
187 client: &'a Client,
188}
189
190impl<'a> SessionsResource<'a> {
191 pub(crate) fn new(client: &'a Client) -> Self {
192 Self { client }
193 }
194
195 pub async fn list(&self, params: SessionListParams) -> Result<Page<Session>> {
197 paginate(self.fetcher(¶ms), ¶ms.pagination(), "data", None).await
198 }
199
200 pub fn list_stream(
202 &self,
203 params: SessionListParams,
204 ) -> impl Stream<Item = Result<Session>> + Send {
205 auto_page(self.fetcher(¶ms), params.pagination(), "data", None)
206 }
207
208 pub async fn get(&self, session_id: &str) -> Result<Session> {
210 let path = format!("{PATH}/{}", escape(session_id));
211 self.client
212 .json(Method::GET, &path, CallOptions::new())
213 .await
214 }
215
216 pub async fn list_participants(
218 &self,
219 session_id: &str,
220 params: ListParams,
221 ) -> Result<Page<Participant>> {
222 let path = format!("{PATH}/{}/participants", escape(session_id));
223 paginate(
224 participants_fetcher(self.client, path),
225 ¶ms,
226 "data",
227 None,
228 )
229 .await
230 }
231
232 pub async fn list_active_participants(
234 &self,
235 session_id: &str,
236 params: ListParams,
237 ) -> Result<Page<Participant>> {
238 let path = format!("{PATH}/{}/participants/active", escape(session_id));
239 paginate(
240 participants_fetcher(self.client, path),
241 ¶ms,
242 "data",
243 None,
244 )
245 .await
246 }
247
248 pub async fn get_participant(
250 &self,
251 session_id: &str,
252 participant_id: &str,
253 ) -> Result<Participant> {
254 let path = format!(
255 "{PATH}/{}/participants/{}",
256 escape(session_id),
257 escape(participant_id)
258 );
259 self.client
260 .json(Method::GET, &path, CallOptions::new())
261 .await
262 }
263
264 pub async fn end(&self, params: SessionEndParams) -> Result<Session> {
266 let room_id = params
267 .room_id
268 .as_deref()
269 .or(params.meeting_id.as_deref())
270 .filter(|id| !id.is_empty())
271 .ok_or_else(|| Error::validation("sessions.end requires room_id (or meeting_id)"))?;
272
273 let body = SessionEndWire {
275 room_id,
276 session_id: params.session_id.as_deref(),
277 ignore_closed: params.ignore_closed,
278 force: params.force,
279 };
280
281 let path = format!("{PATH}/end");
282 let session: Session = self
283 .client
284 .json(Method::POST, &path, CallOptions::json(&body)?)
285 .await?;
286 Ok(normalize_ended_session(session))
287 }
288
289 pub async fn remove_participant(
292 &self,
293 params: SessionRemoveParticipantParams,
294 ) -> Result<String> {
295 let room_id = params.room_id.as_deref().filter(|id| !id.is_empty());
296 let session_id = params.session_id.as_deref().filter(|id| !id.is_empty());
297 if room_id.is_none() && session_id.is_none() {
298 return Err(Error::validation(
299 "sessions.remove_participant requires room_id or session_id to locate the session",
300 ));
301 }
302
303 let body = RemoveParticipantWire {
304 participant_id: ¶ms.participant_id,
305 room_id,
306 session_id,
307 };
308 self.client
309 .message(
310 Method::POST,
311 "/v2/sessions/participants/remove",
312 CallOptions::json(&body)?,
313 )
314 .await
315 }
316
317 pub async fn get_stats(&self, session_id: &str) -> Result<QualityStats> {
319 let path = format!("{PATH}/{}/stats", escape(session_id));
320 self.client
321 .json(Method::GET, &path, CallOptions::new())
322 .await
323 }
324
325 pub async fn get_participant_stats(
327 &self,
328 session_id: &str,
329 participant_id: &str,
330 ) -> Result<QualityStats> {
331 let path = format!(
332 "{PATH}/{}/participant/{}/stats",
333 escape(session_id),
334 escape(participant_id)
335 );
336 self.client
337 .json(Method::GET, &path, CallOptions::new())
338 .await
339 }
340
341 fn fetcher(&self, params: &SessionListParams) -> PageFetcher {
342 let client = self.client.clone();
343 let params = params.clone();
344
345 Arc::new(move |page, per_page| {
346 let client = client.clone();
347 let params = params.clone();
348 Box::pin(async move {
349 let query = QueryBuilder::new()
350 .opt("page", page)
351 .opt("perPage", per_page)
352 .opt_str("query", params.query.as_deref())
353 .opt_str("roomId", params.room_id.as_deref())
354 .opt_str("customRoomId", params.custom_room_id.as_deref())
355 .opt_str("userId", params.user_id.as_deref())
356 .opt("startDate", params.start_date)
357 .opt("endDate", params.end_date)
358 .opt_str("status", params.status.as_ref().map(SessionStatus::as_str))
359 .into_pairs();
360 client
361 .json::<Value>(Method::GET, PATH, CallOptions::new().query(query))
362 .await
363 })
364 })
365 }
366}
367
368pub(crate) fn participants_fetcher(client: &Client, path: String) -> PageFetcher {
370 let client = client.clone();
371 Arc::new(move |page, per_page| {
372 let client = client.clone();
373 let path = path.clone();
374 Box::pin(async move {
375 let query = QueryBuilder::new()
376 .opt("page", page)
377 .opt("perPage", per_page)
378 .into_pairs();
379 let raw: Value = client
380 .json(Method::GET, &path, CallOptions::new().query(query))
381 .await?;
382 Ok(reshape_session_participants(raw))
383 })
384 })
385}
386
387fn normalize_ended_session(mut session: Session) -> Session {
391 if session.room_id.is_empty() {
392 if let Some(meeting_id) = session.extra.get("meetingId").and_then(Value::as_str) {
393 session.room_id = meeting_id.to_string();
394 }
395 }
396 if session.custom_room_id.is_none() {
397 session.custom_room_id = session
398 .extra
399 .get("userMeetingId")
400 .and_then(Value::as_str)
401 .map(str::to_string);
402 }
403 if session.status.is_none() {
404 session.status = Some(if session.end.is_none() {
405 SessionStatus::ONGOING
406 } else {
407 SessionStatus::ENDED
408 });
409 }
410 session
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416 use serde_json::json;
417
418 fn session(value: Value) -> Session {
419 serde_json::from_value(value).unwrap()
420 }
421
422 #[test]
423 fn end_wire_renames_force_to_vsdk_force_kill() {
424 let body = serde_json::to_value(SessionEndWire {
425 room_id: "r-1",
426 session_id: None,
427 ignore_closed: None,
428 force: Some(true),
429 })
430 .unwrap();
431 assert_eq!(body, json!({"roomId": "r-1", "vsdkForceKill": true}));
432 }
433
434 #[test]
435 fn end_wire_omits_absent_options() {
436 let body = serde_json::to_value(SessionEndWire {
437 room_id: "r-1",
438 session_id: Some("s-1"),
439 ignore_closed: Some(false),
440 force: None,
441 })
442 .unwrap();
443 assert_eq!(
444 body,
445 json!({"roomId": "r-1", "sessionId": "s-1", "ignoreClosed": false})
446 );
447 }
448
449 #[test]
450 fn ended_session_folds_the_meeting_id_aliases() {
451 let normalized = normalize_ended_session(session(json!({
452 "id": "s-1",
453 "meetingId": "abcd-efgh-ijkl",
454 "userMeetingId": "my-room",
455 "end": "2026-01-01T00:00:00Z",
456 })));
457 assert_eq!(normalized.room_id, "abcd-efgh-ijkl");
458 assert_eq!(normalized.custom_room_id.as_deref(), Some("my-room"));
459 assert_eq!(normalized.status, Some(SessionStatus::ENDED));
460 }
461
462 #[test]
463 fn ended_session_derives_ongoing_when_end_is_absent() {
464 let normalized = normalize_ended_session(session(json!({"id": "s-1", "roomId": "r-1"})));
465 assert_eq!(normalized.status, Some(SessionStatus::ONGOING));
466 assert_eq!(
467 normalized.room_id, "r-1",
468 "a real roomId is not overwritten"
469 );
470 }
471
472 #[test]
473 fn ended_session_keeps_a_server_supplied_status() {
474 let normalized = normalize_ended_session(session(json!({
475 "id": "s-1", "roomId": "r-1", "status": "ongoing", "end": "2026-01-01T00:00:00Z",
476 })));
477 assert_eq!(normalized.status, Some(SessionStatus::ONGOING));
478 }
479
480 #[test]
481 fn session_status_round_trips() {
482 assert_eq!(
483 serde_json::to_value(SessionStatus::ONGOING).unwrap(),
484 json!("ongoing")
485 );
486 assert_eq!(SessionStatus::ENDED.as_str(), "ended");
487 }
488}