1use std::collections::HashMap;
8use std::sync::Arc;
9
10use futures_util::Stream;
11use reqwest::Method;
12use serde::{Deserialize, Serialize};
13use serde_json::{Map, Value};
14
15use crate::client::{CallOptions, Client};
16use crate::error::{Error, Result};
17use crate::pagination::{auto_page, paginate, ItemMapper, ListParams, Page, PageFetcher};
18use crate::query::QueryBuilder;
19use crate::resources::escape;
20
21const PATH: &str = "/ai/v1/realtime-transcriptions";
22
23#[derive(Debug, Clone, Default, Serialize)]
25pub struct RealtimeSummaryConfig {
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub enabled: Option<bool>,
29 #[serde(skip_serializing_if = "Option::is_none")]
31 pub prompt: Option<String>,
32}
33
34#[derive(Debug, Clone, Default)]
36pub struct StartRealtimeTranscriptionParams {
37 pub webhook_url: Option<String>,
39 pub model_id: Option<String>,
41 pub language: Option<String>,
43 pub summary: Option<RealtimeSummaryConfig>,
45}
46
47#[derive(Debug, Serialize)]
48#[serde(rename_all = "camelCase")]
49struct StartTranscriptionWire<'a> {
50 room_id: &'a str,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 webhook_url: Option<&'a str>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 model_id: Option<&'a str>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 language: Option<&'a str>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 summary: Option<&'a RealtimeSummaryConfig>,
59}
60
61#[derive(Debug, Clone, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct RealtimeTranscriptionExtensionConfig {
65 pub room_id: Option<String>,
67 pub session_id: Option<String>,
69}
70
71#[derive(Debug, Clone, Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct RealtimeTranscriptionExtension {
75 pub enabled: Option<bool>,
77 pub provider: Option<String>,
79 pub extension_config: Option<RealtimeTranscriptionExtensionConfig>,
81}
82
83#[derive(Debug, Clone, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct RealtimeTranscription {
87 pub id: String,
89 pub room_id: Option<String>,
94 pub status: Option<String>,
96 pub extension: Option<RealtimeTranscriptionExtension>,
98 #[serde(default, deserialize_with = "crate::common::null_to_default")]
100 pub transcription_file_paths: HashMap<String, String>,
101 #[serde(default, deserialize_with = "crate::common::null_to_default")]
103 pub summarized_file_paths: HashMap<String, String>,
104 pub start: Option<String>,
106 pub end: Option<String>,
108 #[serde(flatten)]
110 pub extra: Map<String, Value>,
111}
112
113fn with_room_id(
116 mut transcription: RealtimeTranscription,
117 fallback: Option<&str>,
118) -> RealtimeTranscription {
119 if transcription.room_id.is_some() {
120 return transcription;
121 }
122 let nested = transcription
123 .extension
124 .as_ref()
125 .and_then(|extension| extension.extension_config.as_ref())
126 .and_then(|config| config.room_id.clone());
127
128 transcription.room_id = nested.or_else(|| fallback.map(str::to_string));
129 transcription
130}
131
132#[derive(Debug, Clone, Default)]
136pub struct ListRealtimeTranscriptionsParams {
137 pub page: Option<u32>,
139 pub per_page: Option<u32>,
141 pub cursor: Option<String>,
143 pub status: Option<String>,
145}
146
147impl ListRealtimeTranscriptionsParams {
148 fn pagination(&self) -> ListParams {
149 ListParams {
150 page: self.page,
151 per_page: self.per_page,
152 cursor: self.cursor.clone(),
153 }
154 }
155}
156
157fn transcription_item_mapper() -> ItemMapper<RealtimeTranscription> {
158 Arc::new(|value: Value| {
159 let transcription: RealtimeTranscription = serde_json::from_value(value)
160 .map_err(|e| Error::decode("realtime transcription list item", e))?;
161 Ok(with_room_id(transcription, None))
162 })
163}
164
165#[derive(Debug, Clone, Copy)]
168pub struct RealtimeTranscriptionResource<'a> {
169 client: &'a Client,
170}
171
172impl<'a> RealtimeTranscriptionResource<'a> {
173 pub async fn start(
175 &self,
176 room_id: &str,
177 params: StartRealtimeTranscriptionParams,
178 ) -> Result<RealtimeTranscription> {
179 let body = StartTranscriptionWire {
180 room_id,
181 webhook_url: params.webhook_url.as_deref(),
182 model_id: params.model_id.as_deref(),
183 language: params.language.as_deref(),
184 summary: params.summary.as_ref(),
185 };
186 let path = format!("{PATH}/start");
187 let transcription: RealtimeTranscription = self
188 .client
189 .json(Method::POST, &path, CallOptions::json(&body)?)
190 .await?;
191 Ok(with_room_id(transcription, Some(room_id)))
192 }
193
194 pub async fn stop(&self, room_id: &str) -> Result<RealtimeTranscription> {
196 if room_id.is_empty() {
197 return Err(Error::validation(
198 "transcription.realtime().stop() needs a room_id",
199 ));
200 }
201 let body = serde_json::json!({ "roomId": room_id });
202 let path = format!("{PATH}/end");
203 self.client
204 .json(Method::POST, &path, CallOptions::json(&body)?)
205 .await
206 }
207
208 pub async fn get(&self, transcription_id: &str) -> Result<RealtimeTranscription> {
210 let path = format!("{PATH}/{}", escape(transcription_id));
211 let transcription: RealtimeTranscription = self
212 .client
213 .json(Method::GET, &path, CallOptions::new())
214 .await?;
215 Ok(with_room_id(transcription, None))
216 }
217
218 pub async fn list(
220 &self,
221 params: ListRealtimeTranscriptionsParams,
222 ) -> Result<Page<RealtimeTranscription>> {
223 paginate(
224 self.fetcher(¶ms),
225 ¶ms.pagination(),
226 "transcriptions",
227 Some(transcription_item_mapper()),
228 )
229 .await
230 }
231
232 pub fn list_stream(
234 &self,
235 params: ListRealtimeTranscriptionsParams,
236 ) -> impl Stream<Item = Result<RealtimeTranscription>> + Send {
237 auto_page(
238 self.fetcher(¶ms),
239 params.pagination(),
240 "transcriptions",
241 Some(transcription_item_mapper()),
242 )
243 }
244
245 fn fetcher(&self, params: &ListRealtimeTranscriptionsParams) -> PageFetcher {
246 let client = self.client.clone();
247 let status = params.status.clone();
248 Arc::new(move |page, per_page| {
249 let client = client.clone();
250 let status = status.clone();
251 Box::pin(async move {
252 let query = QueryBuilder::new()
253 .opt("page", page)
254 .opt("perPage", per_page)
255 .opt_str("status", status.as_deref())
256 .into_pairs();
257 client
258 .json::<Value>(Method::GET, PATH, CallOptions::new().query(query))
259 .await
260 })
261 })
262 }
263}
264
265#[derive(Debug, Clone, Copy)]
267pub struct TranscriptionResource<'a> {
268 client: &'a Client,
269}
270
271impl<'a> TranscriptionResource<'a> {
272 pub(crate) fn new(client: &'a Client) -> Self {
273 Self { client }
274 }
275
276 pub fn realtime(&self) -> RealtimeTranscriptionResource<'a> {
278 RealtimeTranscriptionResource {
279 client: self.client,
280 }
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use serde_json::json;
288
289 fn transcription(value: Value) -> RealtimeTranscription {
290 serde_json::from_value(value).unwrap()
291 }
292
293 #[test]
294 fn the_room_id_is_lifted_out_of_the_extension_config() {
295 let lifted = with_room_id(
296 transcription(json!({
297 "id": "t-1",
298 "extension": {"extensionConfig": {"roomId": "r-9"}},
299 })),
300 None,
301 );
302 assert_eq!(lifted.room_id.as_deref(), Some("r-9"));
303 }
304
305 #[test]
306 fn a_top_level_room_id_is_never_overwritten() {
307 let lifted = with_room_id(
308 transcription(json!({
309 "id": "t-1",
310 "roomId": "real",
311 "extension": {"extensionConfig": {"roomId": "nested"}},
312 })),
313 Some("fallback"),
314 );
315 assert_eq!(lifted.room_id.as_deref(), Some("real"));
316 }
317
318 #[test]
319 fn the_fallback_applies_only_when_nothing_else_resolves() {
320 let lifted = with_room_id(transcription(json!({"id": "t-1"})), Some("fallback"));
321 assert_eq!(lifted.room_id.as_deref(), Some("fallback"));
322
323 let lifted = with_room_id(transcription(json!({"id": "t-1"})), None);
324 assert_eq!(lifted.room_id, None);
325 }
326}