diff --git a/crates/wavekat-platform-client/src/client.rs b/crates/wavekat-platform-client/src/client.rs index c932bd2..baae7a3 100644 --- a/crates/wavekat-platform-client/src/client.rs +++ b/crates/wavekat-platform-client/src/client.rs @@ -281,6 +281,21 @@ impl Client { sink.flush().await?; Ok(written) } + + /// `GET {path}` returning the raw response body in memory. For small + /// binary payloads a caller wants as a `Vec` — e.g. a frozen + /// flow-audio clip (tens of KB) to hand to an atomic on-disk writer. + /// For large streams prefer [`Client::get_stream_to`]. + pub async fn get_bytes(&self, path: &str) -> Result> { + let url = self.url(path); + let resp = self.inner.get(&url).send().await?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(http_error(status.as_u16(), url, body)); + } + Ok(resp.bytes().await?.to_vec()) + } } async fn decode(url: String, resp: reqwest::Response) -> Result { diff --git a/crates/wavekat-platform-client/src/lib.rs b/crates/wavekat-platform-client/src/lib.rs index e4e3d3d..db0c245 100644 --- a/crates/wavekat-platform-client/src/lib.rs +++ b/crates/wavekat-platform-client/src/lib.rs @@ -59,8 +59,9 @@ pub use voice::{ InstallHeartbeatRequest, InstallHeartbeatResponse, PartyMasking, ShareRecordingRequest, ShareRecordingResponse, ShareVisibility, SystemInfo, VoiceAccountRecord, VoiceAccounts, VoiceAccountsQuery, VoiceCallCodec, VoiceCallDirection, VoiceCallDisposition, - VoiceCallEndReason, VoiceCallRecord, VoiceCalls, VoiceCallsQuery, VoiceFlowRecord, - VoiceFlowsPage, VoiceFlowsQuery, VoiceRecordingRecord, VoiceRecordingSyncItem, VoiceRecordings, - VoiceRecordingsQuery, VoiceRecordingsSyncResponse, VoiceTranscriptChannel, - VoiceTranscriptRecord, VoiceTranscripts, VoiceTranscriptsQuery, VoiceTransport, + VoiceCallEndReason, VoiceCallRecord, VoiceCalls, VoiceCallsQuery, VoiceFlowAssetsPage, + VoiceFlowRecord, VoiceFlowVersionAsset, VoiceFlowsPage, VoiceFlowsQuery, VoiceRecordingRecord, + VoiceRecordingSyncItem, VoiceRecordings, VoiceRecordingsQuery, VoiceRecordingsSyncResponse, + VoiceTranscriptChannel, VoiceTranscriptRecord, VoiceTranscripts, VoiceTranscriptsQuery, + VoiceTransport, }; diff --git a/crates/wavekat-platform-client/src/voice.rs b/crates/wavekat-platform-client/src/voice.rs index 87b5f75..2acd4d8 100644 --- a/crates/wavekat-platform-client/src/voice.rs +++ b/crates/wavekat-platform-client/src/voice.rs @@ -511,6 +511,40 @@ pub struct VoiceFlowsPage { pub next_after: Option, } +/// One frozen audio asset of a published flow version, as served by +/// `GET /api/voice/flows/{id}/versions/{version}/assets` (wavekat-platform +/// docs 16/17). The bytes were copied into a version-owned R2 object at +/// publish time and never change, so `content_hash` identifies them +/// exactly — the daemon diffs its local cache against it rather than +/// trusting a bare filename, because the *same* `ref` can carry different +/// bytes across two versions of the same flow. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VoiceFlowVersionAsset { + /// The `vprompt_…` reference exactly as it appears in the flow YAML. + #[serde(rename = "ref")] + pub asset_ref: String, + /// Source telephony format the clip was frozen as (`ulaw_8000`, + /// `pcm_16000`, `mp3`, …); the container is WAV unless `mp3`. + pub format: String, + /// Size of the frozen bytes. + pub byte_size: u64, + /// Clip duration if the platform knew it at freeze time. + #[serde(default)] + pub duration_ms: Option, + /// sha256 of the frozen bytes — the cache's content key. + pub content_hash: String, +} + +/// The frozen-asset manifest for one published version. Not paginated: +/// a flow's asset count is bounded by its node count (a phone tree is +/// tens of clips), so the platform returns them all in one response. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VoiceFlowAssetsPage { + pub assets: Vec, +} + impl Client { /// `GET /api/voice/flows/published` — one page of the caller's /// published flow snapshots (latest version each). Strictly @@ -519,6 +553,36 @@ impl Client { self.get_json_query::("/api/voice/flows/published", query) .await } + + /// `GET /api/voice/flows/{id}/versions/{version}/assets` — the frozen + /// audio manifest for one published version (docs 16/17). Flow-scoped + /// server-side: a version of a flow the caller doesn't own is a 404, + /// never another user's assets. An existing, visible version with no + /// generated audio returns an empty manifest. + pub async fn flow_version_assets( + &self, + flow_id: &str, + version: u32, + ) -> Result { + let path = format!("/api/voice/flows/{flow_id}/versions/{version}/assets"); + self.get_json::(&path).await + } + + /// `GET /api/voice/flows/{id}/versions/{version}/assets/{ref}/bytes` — + /// the immutable frozen copy of one clip, served from the version's own + /// asset set (never the mutable library). Returned in memory because a + /// clip is tens of KB and the daemon writes it atomically into its + /// on-disk cache; same flow-scoped 404 as the manifest. + pub async fn flow_version_asset_bytes( + &self, + flow_id: &str, + version: u32, + asset_ref: &str, + ) -> Result> { + let path = + format!("/api/voice/flows/{flow_id}/versions/{version}/assets/{asset_ref}/bytes"); + self.get_bytes(&path).await + } } // ---- Anonymous install heartbeat ------------------------------------------ @@ -1779,6 +1843,33 @@ mod tests { assert_eq!(more.next_after.as_deref(), Some("flow_1")); } + #[test] + fn flow_assets_manifest_parses_platform_shape() { + // `ref` (a reserved word) maps to `asset_ref`; a null duration is + // accepted (the platform doesn't always know it). + let raw = r#"{ + "assets": [{ + "ref": "vprompt_ab12cd34", + "format": "ulaw_8000", + "byteSize": 48044, + "durationMs": null, + "contentHash": "9f2c00aa" + }] + }"#; + let page: VoiceFlowAssetsPage = serde_json::from_str(raw).unwrap(); + assert_eq!(page.assets.len(), 1); + let asset = &page.assets[0]; + assert_eq!(asset.asset_ref, "vprompt_ab12cd34"); + assert_eq!(asset.format, "ulaw_8000"); + assert_eq!(asset.byte_size, 48044); + assert!(asset.duration_ms.is_none()); + assert_eq!(asset.content_hash, "9f2c00aa"); + + // A text-only version legitimately has no frozen audio. + let empty: VoiceFlowAssetsPage = serde_json::from_str(r#"{ "assets": [] }"#).unwrap(); + assert!(empty.assets.is_empty()); + } + #[test] fn account_record_accepts_unknown_extras_for_forward_compat() { // A newer client shipping a field this platform version lacks a