1use async_trait::async_trait;
2use serde_json::{json, Value};
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use crate::internal::channels::channel::deliver_and_store_bot_message;
7use crate::internal::channels::channel_adapter::ChannelRegistry;
8use crate::internal::storage::db::Database;
9use crate::internal::tool_runtime::media_client::{persist_output, MediaClient};
10use crate::internal::tool_runtime::runtime::auth_context_from_input;
11use microclaw_core::llm_types::ToolDefinition;
12
13use super::{schema_object, Tool, ToolResult};
14use crate::config::{Config, MediaConfig, TtsConfig};
15
16fn nonempty(s: Option<&str>) -> Option<String> {
18 s.map(str::trim)
19 .filter(|s| !s.is_empty())
20 .map(str::to_string)
21}
22
23const ALLOWED_FORMATS: &[&str] = &["mp3", "opus", "aac", "flac", "wav", "pcm"];
24const ALLOWED_VOICES: &[&str] = &[
25 "alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse",
26];
27
28pub struct TextToSpeechTool {
34 data_dir: PathBuf,
35 channels: Arc<ChannelRegistry>,
36 db: Arc<Database>,
37 cfg: TtsConfig,
38 media: MediaConfig,
39 openai_api_key: Option<String>,
40 openai_base_url: Option<String>,
41 timeout_secs: u64,
42}
43
44impl TextToSpeechTool {
45 pub fn new(config: &Config, channels: Arc<ChannelRegistry>, db: Arc<Database>) -> Self {
46 Self {
47 data_dir: PathBuf::from(&config.data_dir),
48 channels,
49 db,
50 cfg: config.media.tts.clone(),
51 media: config.media.clone(),
52 openai_api_key: config.openai_api_key.clone(),
53 openai_base_url: config.openai_base_url.clone(),
54 timeout_secs: config.tool_timeout_secs("text_to_speech", 60),
55 }
56 }
57
58 fn client(&self) -> Result<MediaClient, String> {
59 let key = nonempty(self.cfg.api_key.as_deref())
61 .or_else(|| self.media.resolve_api_key(self.openai_api_key.as_deref()))
62 .ok_or_else(|| {
63 "text_to_speech requires an API key (media.tts.api_key, \
64 media.api_key, MICROCLAW_OPENAI_API_KEY, OPENAI_API_KEY, or \
65 top-level openai_api_key)."
66 .to_string()
67 })?;
68 let base = nonempty(self.cfg.base_url.as_deref())
69 .map(|s| s.trim_end_matches('/').to_string())
70 .unwrap_or_else(|| self.media.resolve_base_url(self.openai_base_url.as_deref()));
71 MediaClient::new(base, key, self.timeout_secs)
72 }
73}
74
75#[async_trait]
76impl Tool for TextToSpeechTool {
77 fn name(&self) -> &str {
78 "text_to_speech"
79 }
80
81 fn definition(&self) -> ToolDefinition {
82 ToolDefinition {
83 name: self.name().into(),
84 description: "Synthesize speech from text via an OpenAI-compatible \
85 /audio/speech endpoint. Saves the audio under the bot's data \
86 directory and — when the active channel supports attachments — \
87 also sends it back to the user inline. Disabled by default; \
88 requires operator opt-in via `media.tts.enabled`."
89 .into(),
90 input_schema: schema_object(
91 json!({
92 "text": {
93 "type": "string",
94 "description": "What to say (max ~4096 chars for most providers)."
95 },
96 "voice": {
97 "type": "string",
98 "enum": ALLOWED_VOICES,
99 "description": "Voice preset. Defaults to media.tts.default_voice."
100 },
101 "format": {
102 "type": "string",
103 "enum": ALLOWED_FORMATS,
104 "description": "Audio format. Defaults to media.tts.default_format."
105 },
106 "model": {
107 "type": "string",
108 "description": "Optional model override (e.g. tts-1, tts-1-hd, gpt-4o-mini-tts)."
109 },
110 "deliver": {
111 "type": "boolean",
112 "description": "Whether to attempt channel delivery (default true)."
113 }
114 }),
115 &["text"],
116 ),
117 }
118 }
119
120 async fn execute(&self, input: Value) -> ToolResult {
121 if !self.cfg.enabled {
122 return ToolResult::error(
123 "text_to_speech is disabled. Set media.tts.enabled=true to enable.".into(),
124 );
125 }
126 let text = match input.get("text").and_then(|v| v.as_str()) {
127 Some(t) if !t.trim().is_empty() => t.trim().to_string(),
128 _ => return ToolResult::error("Missing parameter: text".into()),
129 };
130 if text.chars().count() > 4096 {
131 return ToolResult::error(
132 "text exceeds 4096 characters — split into smaller chunks".into(),
133 );
134 }
135 let voice = input
136 .get("voice")
137 .and_then(|v| v.as_str())
138 .map(|s| s.to_string())
139 .unwrap_or_else(|| self.cfg.default_voice.clone());
140 if !ALLOWED_VOICES.iter().any(|v| v == &voice.as_str()) {
141 return ToolResult::error(format!(
142 "invalid voice '{voice}'. Allowed: {}",
143 ALLOWED_VOICES.join(", ")
144 ));
145 }
146 let format = input
147 .get("format")
148 .and_then(|v| v.as_str())
149 .map(|s| s.to_string())
150 .unwrap_or_else(|| self.cfg.default_format.clone());
151 if !ALLOWED_FORMATS.iter().any(|v| v == &format.as_str()) {
152 return ToolResult::error(format!(
153 "invalid format '{format}'. Allowed: {}",
154 ALLOWED_FORMATS.join(", ")
155 ));
156 }
157 let model = input
158 .get("model")
159 .and_then(|v| v.as_str())
160 .map(|s| s.to_string())
161 .unwrap_or_else(|| self.cfg.model.clone());
162 let deliver = input
163 .get("deliver")
164 .and_then(|v| v.as_bool())
165 .unwrap_or(true);
166
167 let client = match self.client() {
168 Ok(c) => c,
169 Err(e) => return ToolResult::error(e),
170 };
171
172 let body = json!({
173 "model": model,
174 "voice": voice,
175 "input": text,
176 "response_format": format,
177 });
178
179 let resp = match client.post_json("audio/speech").json(&body).send().await {
180 Ok(r) => r,
181 Err(e) => return ToolResult::error(format!("audio/speech request failed: {e}")),
182 };
183 if !resp.status().is_success() {
184 let status = resp.status();
185 let txt = resp.text().await.unwrap_or_default();
186 return ToolResult::error(format!("audio/speech HTTP {status}: {txt}"));
187 }
188 let bytes = match resp.bytes().await {
189 Ok(b) => b.to_vec(),
190 Err(e) => return ToolResult::error(format!("audio body read failed: {e}")),
191 };
192
193 let saved = match persist_output(&self.data_dir, "audio", &format, &bytes) {
194 Ok(p) => p,
195 Err(e) => return ToolResult::error(format!("failed to save audio: {e}")),
196 };
197
198 let mut summary = format!(
199 "TTS {} voice={} format={} ({} bytes) -> {}",
200 model,
201 voice,
202 format,
203 bytes.len(),
204 saved.display()
205 );
206 if deliver {
207 if let Some(auth) = auth_context_from_input(&input) {
208 match deliver_attachment(
209 &self.channels,
210 self.db.clone(),
211 auth.caller_chat_id,
212 &saved,
213 &text,
214 )
215 .await
216 {
217 Ok(msg) => summary.push_str(&format!("; {msg}")),
218 Err(e) => summary.push_str(&format!("; delivery skipped: {e}")),
219 }
220 }
221 }
222 ToolResult::success(summary).with_metadata(json!({
223 "path": saved.to_string_lossy(),
224 "voice": voice,
225 "format": format,
226 "model": model,
227 }))
228 }
229}
230
231async fn deliver_attachment(
232 channels: &ChannelRegistry,
233 db: Arc<Database>,
234 chat_id: i64,
235 file: &std::path::Path,
236 caption: &str,
237) -> Result<String, String> {
238 let routing = match crate::internal::channels::channel::get_required_chat_routing(
239 channels,
240 db.clone(),
241 chat_id,
242 )
243 .await
244 {
245 Ok(r) => r,
246 Err(e) => return Err(e),
247 };
248 let Some(adapter) = channels.get(&routing.channel_name) else {
249 return Err(format!("no adapter for channel '{}'", routing.channel_name));
250 };
251 if adapter.is_local_only() {
252 return Ok(format!(
253 "channel '{}' is local-only, path retained at: {}",
254 routing.channel_name,
255 file.display()
256 ));
257 }
258 let external_chat_id = crate::internal::storage::db::call_blocking(db.clone(), move |d| {
259 d.get_chat_external_id(chat_id)
260 })
261 .await
262 .map_err(|e| e.to_string())?
263 .unwrap_or_else(|| chat_id.to_string());
264 let caption_short = caption.chars().take(120).collect::<String>();
265 match adapter
266 .send_attachment(&external_chat_id, file, Some(&caption_short))
267 .await
268 {
269 Ok(_) => {
270 let _ = deliver_and_store_bot_message(
271 channels,
272 db,
273 "bot",
274 chat_id,
275 &format!("[audio attached: {}]", file.display()),
276 )
277 .await;
278 Ok(format!("delivered via channel '{}'", routing.channel_name))
279 }
280 Err(e) => Err(e),
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
289 fn voice_and_format_constants_wellformed() {
290 assert!(ALLOWED_VOICES.contains(&"alloy"));
291 assert!(ALLOWED_FORMATS.contains(&"mp3"));
292 }
293}