1use super::{Tool, ToolResult};
8use anyhow::{Context, Result};
9use async_trait::async_trait;
10use serde::Deserialize;
11use serde_json::{Value, json};
12use std::time::Duration;
13
14const REQUEST_TIMEOUT: Duration = Duration::from_secs(300); fn voice_api_url() -> String {
17 std::env::var("CODETETHER_VOICE_API_URL")
18 .unwrap_or_else(|_| "https://voice.quantum-forge.io".to_string())
19}
20
21pub struct PodcastTool {
22 client: reqwest::Client,
23}
24
25impl Default for PodcastTool {
26 fn default() -> Self {
27 Self::new()
28 }
29}
30
31impl PodcastTool {
32 pub fn new() -> Self {
33 let client = reqwest::Client::builder()
34 .timeout(REQUEST_TIMEOUT)
35 .user_agent("CodeTether-Agent/1.0")
36 .build()
37 .expect("Failed to build HTTP client");
38 Self { client }
39 }
40
41 async fn create_podcast(&self, params: &CreatePodcastParams) -> Result<ToolResult> {
42 let base = voice_api_url();
43 let url = format!("{base}/podcasts");
44
45 let mut form = reqwest::multipart::Form::new()
46 .text("title", params.title.clone())
47 .text("description", params.description.clone())
48 .text(
49 "author",
50 params
51 .author
52 .clone()
53 .unwrap_or_else(|| "CodeTether Agent".into()),
54 );
55
56 if let Some(ref email) = params.email {
57 form = form.text("email", email.clone());
58 }
59 if let Some(ref category) = params.category {
60 form = form.text("category", category.clone());
61 }
62 if let Some(ref subcategory) = params.subcategory {
63 form = form.text("subcategory", subcategory.clone());
64 }
65 if let Some(ref language) = params.language {
66 form = form.text("language", language.clone());
67 }
68
69 let resp = self
70 .client
71 .post(&url)
72 .multipart(form)
73 .send()
74 .await
75 .map_err(|e| anyhow::anyhow!("Create podcast request failed: {e}"))?;
76
77 if !resp.status().is_success() {
78 let status = resp.status();
79 let body = resp.text().await.unwrap_or_default();
80 return Ok(ToolResult::error(format!(
81 "Create podcast failed ({status}): {body}"
82 )));
83 }
84
85 let body: Value = resp.json().await.context("Failed to parse response")?;
86 let podcast_id = body["podcast_id"].as_str().unwrap_or("unknown");
87 let feed_url = body["feed_url"].as_str().unwrap_or("unknown");
88
89 Ok(ToolResult::success(format!(
90 "Podcast created!\n\
91 ID: {podcast_id}\n\
92 Title: {}\n\
93 Feed URL: {feed_url}\n\
94 \n\
95 Next: Add episodes with action 'create_episode' using this podcast_id.",
96 params.title
97 ))
98 .with_metadata("podcast_id", json!(podcast_id))
99 .with_metadata("feed_url", json!(feed_url)))
100 }
101
102 async fn create_episode(&self, params: &CreateEpisodeParams) -> Result<ToolResult> {
103 let base = voice_api_url();
104 let url = format!("{base}/podcasts/{}/episodes", params.podcast_id);
105
106 let voice_id = params.voice_id.as_deref().unwrap_or("960f89fc");
107
108 let mut form = reqwest::multipart::Form::new()
109 .text("title", params.title.clone())
110 .text("script", params.script.clone())
111 .text("voice_id", voice_id.to_string());
112
113 if let Some(ref desc) = params.description {
114 form = form.text("description", desc.clone());
115 }
116 if let Some(num) = params.episode_number {
117 form = form.text("episode_number", num.to_string());
118 }
119 if let Some(season) = params.season_number {
120 form = form.text("season_number", season.to_string());
121 }
122
123 let resp = self
124 .client
125 .post(&url)
126 .multipart(form)
127 .send()
128 .await
129 .map_err(|e| anyhow::anyhow!("Create episode request failed: {e}"))?;
130
131 if !resp.status().is_success() {
132 let status = resp.status();
133 let body = resp.text().await.unwrap_or_default();
134 return Ok(ToolResult::error(format!(
135 "Create episode failed ({status}): {body}"
136 )));
137 }
138
139 let body: Value = resp.json().await.context("Failed to parse response")?;
140 let episode_id = body["episode_id"].as_str().unwrap_or("unknown");
141 let audio_url = body["audio_url"].as_str().unwrap_or("unknown");
142 let duration = body["duration"].as_str().unwrap_or("unknown");
143 let feed_url = body["feed_url"].as_str().unwrap_or("unknown");
144
145 Ok(ToolResult::success(format!(
146 "Episode created!\n\
147 Episode ID: {episode_id}\n\
148 Title: {}\n\
149 Duration: {duration}\n\
150 Audio: {audio_url}\n\
151 Feed: {feed_url}\n\
152 \n\
153 The RSS feed has been updated automatically.",
154 params.title
155 ))
156 .with_metadata("episode_id", json!(episode_id))
157 .with_metadata("audio_url", json!(audio_url))
158 .with_metadata("duration", json!(duration)))
159 }
160
161 async fn list_podcasts(&self) -> Result<ToolResult> {
162 let base = voice_api_url();
163 let url = format!("{base}/podcasts");
164
165 let resp = self
166 .client
167 .get(&url)
168 .send()
169 .await
170 .map_err(|e| anyhow::anyhow!("List podcasts failed: {e}"))?;
171
172 if !resp.status().is_success() {
173 let status = resp.status();
174 let body = resp.text().await.unwrap_or_default();
175 return Ok(ToolResult::error(format!(
176 "List podcasts failed ({status}): {body}"
177 )));
178 }
179
180 let body: Value = resp.json().await.context("Failed to parse response")?;
181 let podcasts = body["podcasts"].as_array().or_else(|| body.as_array());
182
183 match podcasts {
184 Some(podcasts) if !podcasts.is_empty() => {
185 let mut output = format!("Found {} podcast(s):\n\n", podcasts.len());
186 for p in podcasts {
187 let id = p["podcast_id"].as_str().unwrap_or("?");
188 let title = p["title"].as_str().unwrap_or("Untitled");
189 let eps = p["episode_count"].as_u64().unwrap_or(0);
190 let feed = p["feed_url"].as_str().unwrap_or("?");
191 output.push_str(&format!(
192 "- **{title}** (id: {id}, {eps} episodes)\n Feed: {feed}\n"
193 ));
194 }
195 Ok(ToolResult::success(output).with_metadata("count", json!(podcasts.len())))
196 }
197 _ => Ok(ToolResult::success(
198 "No podcasts found. Create one with action 'create_podcast'.",
199 )),
200 }
201 }
202
203 async fn get_podcast(&self, podcast_id: &str) -> Result<ToolResult> {
204 let base = voice_api_url();
205 let url = format!("{base}/podcasts/{podcast_id}");
206
207 let resp = self
208 .client
209 .get(&url)
210 .send()
211 .await
212 .map_err(|e| anyhow::anyhow!("Get podcast failed: {e}"))?;
213
214 if !resp.status().is_success() {
215 let status = resp.status();
216 let body = resp.text().await.unwrap_or_default();
217 return Ok(ToolResult::error(format!(
218 "Get podcast failed ({status}): {body}"
219 )));
220 }
221
222 let body: Value = resp.json().await.context("Failed to parse response")?;
223 let formatted = serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string());
224
225 Ok(ToolResult::success(formatted))
226 }
227
228 async fn delete_podcast(&self, podcast_id: &str) -> Result<ToolResult> {
229 let base = voice_api_url();
230 let url = format!("{base}/podcasts/{podcast_id}");
231
232 let resp = self
233 .client
234 .delete(&url)
235 .send()
236 .await
237 .map_err(|e| anyhow::anyhow!("Delete podcast failed: {e}"))?;
238
239 if !resp.status().is_success() {
240 let status = resp.status();
241 let body = resp.text().await.unwrap_or_default();
242 return Ok(ToolResult::error(format!(
243 "Delete podcast failed ({status}): {body}"
244 )));
245 }
246
247 Ok(ToolResult::success(format!(
248 "Podcast {podcast_id} and all its episodes have been deleted."
249 )))
250 }
251
252 async fn delete_episode(&self, podcast_id: &str, episode_id: &str) -> Result<ToolResult> {
253 let base = voice_api_url();
254 let url = format!("{base}/podcasts/{podcast_id}/episodes/{episode_id}");
255
256 let resp = self
257 .client
258 .delete(&url)
259 .send()
260 .await
261 .map_err(|e| anyhow::anyhow!("Delete episode failed: {e}"))?;
262
263 if !resp.status().is_success() {
264 let status = resp.status();
265 let body = resp.text().await.unwrap_or_default();
266 return Ok(ToolResult::error(format!(
267 "Delete episode failed ({status}): {body}"
268 )));
269 }
270
271 Ok(ToolResult::success(format!(
272 "Episode {episode_id} deleted from podcast {podcast_id}. RSS feed updated."
273 )))
274 }
275}
276
277#[derive(Deserialize)]
278struct Params {
279 action: String,
280 #[serde(default)]
281 podcast_id: Option<String>,
282 #[serde(default)]
283 episode_id: Option<String>,
284 #[serde(default)]
285 title: Option<String>,
286 #[serde(default)]
287 description: Option<String>,
288 #[serde(default)]
289 author: Option<String>,
290 #[serde(default)]
291 email: Option<String>,
292 #[serde(default)]
293 category: Option<String>,
294 #[serde(default)]
295 subcategory: Option<String>,
296 #[serde(default)]
297 language: Option<String>,
298 #[serde(default)]
299 script: Option<String>,
300 #[serde(default)]
301 voice_id: Option<String>,
302 #[serde(default)]
303 episode_number: Option<i64>,
304 #[serde(default)]
305 season_number: Option<i64>,
306}
307
308#[derive(Deserialize)]
309struct CreatePodcastParams {
310 title: String,
311 description: String,
312 author: Option<String>,
313 email: Option<String>,
314 category: Option<String>,
315 subcategory: Option<String>,
316 language: Option<String>,
317}
318
319#[derive(Deserialize)]
320struct CreateEpisodeParams {
321 podcast_id: String,
322 title: String,
323 script: String,
324 voice_id: Option<String>,
325 description: Option<String>,
326 episode_number: Option<i64>,
327 season_number: Option<i64>,
328}
329
330#[async_trait]
331impl Tool for PodcastTool {
332 fn id(&self) -> &str {
333 "podcast"
334 }
335 fn name(&self) -> &str {
336 "Podcast"
337 }
338 fn description(&self) -> &str {
339 "Create and manage AI-generated podcasts with cloned voice narration. \
340 Actions: create_podcast (new podcast series), create_episode (generate TTS episode from script), \
341 list_podcasts (show all podcasts), get_podcast (details + episodes), \
342 delete_podcast, delete_episode. Episodes are auto-converted to MP3 with RSS feed generation."
343 }
344 fn parameters(&self) -> Value {
345 json!({
346 "type": "object",
347 "properties": {
348 "action": {
349 "type": "string",
350 "enum": ["create_podcast", "create_episode", "list_podcasts", "get_podcast", "delete_podcast", "delete_episode"],
351 "description": "Action to perform"
352 },
353 "podcast_id": {
354 "type": "string",
355 "description": "Podcast ID (required for create_episode, get_podcast, delete_podcast, delete_episode)"
356 },
357 "episode_id": {
358 "type": "string",
359 "description": "Episode ID (required for delete_episode)"
360 },
361 "title": {
362 "type": "string",
363 "description": "Title for podcast or episode"
364 },
365 "description": {
366 "type": "string",
367 "description": "Description for podcast or episode"
368 },
369 "author": {
370 "type": "string",
371 "description": "Podcast author name (default: CodeTether Agent)"
372 },
373 "email": {
374 "type": "string",
375 "description": "Contact email for podcast"
376 },
377 "category": {
378 "type": "string",
379 "description": "Podcast category (default: Technology)"
380 },
381 "subcategory": {
382 "type": "string",
383 "description": "Podcast subcategory"
384 },
385 "language": {
386 "type": "string",
387 "description": "Podcast language code (default: en-us)"
388 },
389 "script": {
390 "type": "string",
391 "description": "Episode script text to convert to speech (required for create_episode)"
392 },
393 "voice_id": {
394 "type": "string",
395 "description": "Voice profile ID for TTS (default: Riley voice 960f89fc)"
396 },
397 "episode_number": {
398 "type": "integer",
399 "description": "Episode number"
400 },
401 "season_number": {
402 "type": "integer",
403 "description": "Season number"
404 }
405 },
406 "required": ["action"]
407 })
408 }
409
410 async fn execute(&self, params: Value) -> Result<ToolResult> {
411 let p: Params = serde_json::from_value(params).context("Invalid params")?;
412
413 match p.action.as_str() {
414 "create_podcast" => {
415 let title = match p.title {
416 Some(t) if !t.trim().is_empty() => t,
417 _ => {
418 return Ok(ToolResult::structured_error(
419 "MISSING_PARAM",
420 "podcast",
421 "'title' is required for create_podcast",
422 Some(vec!["title"]),
423 Some(
424 json!({"action": "create_podcast", "title": "My Podcast", "description": "A tech podcast"}),
425 ),
426 ));
427 }
428 };
429 let description = match p.description {
430 Some(d) if !d.trim().is_empty() => d,
431 _ => {
432 return Ok(ToolResult::structured_error(
433 "MISSING_PARAM",
434 "podcast",
435 "'description' is required for create_podcast",
436 Some(vec!["description"]),
437 Some(
438 json!({"action": "create_podcast", "title": "My Podcast", "description": "A tech podcast"}),
439 ),
440 ));
441 }
442 };
443 self.create_podcast(&CreatePodcastParams {
444 title,
445 description,
446 author: p.author,
447 email: p.email,
448 category: p.category,
449 subcategory: p.subcategory,
450 language: p.language,
451 })
452 .await
453 }
454 "create_episode" => {
455 let podcast_id = match p.podcast_id {
456 Some(id) if !id.trim().is_empty() => id,
457 _ => {
458 return Ok(ToolResult::structured_error(
459 "MISSING_PARAM",
460 "podcast",
461 "'podcast_id' is required for create_episode",
462 Some(vec!["podcast_id"]),
463 Some(
464 json!({"action": "create_episode", "podcast_id": "abc12345", "title": "Episode 1", "script": "Hello listeners..."}),
465 ),
466 ));
467 }
468 };
469 let title = match p.title {
470 Some(t) if !t.trim().is_empty() => t,
471 _ => {
472 return Ok(ToolResult::structured_error(
473 "MISSING_PARAM",
474 "podcast",
475 "'title' is required for create_episode",
476 Some(vec!["title"]),
477 Some(
478 json!({"action": "create_episode", "podcast_id": "abc12345", "title": "Episode 1", "script": "Hello listeners..."}),
479 ),
480 ));
481 }
482 };
483 let script = match p.script {
484 Some(s) if !s.trim().is_empty() => s,
485 _ => {
486 return Ok(ToolResult::structured_error(
487 "MISSING_PARAM",
488 "podcast",
489 "'script' is required for create_episode — this is the text that will be converted to speech",
490 Some(vec!["script"]),
491 Some(
492 json!({"action": "create_episode", "podcast_id": "abc12345", "title": "Episode 1", "script": "Hello listeners..."}),
493 ),
494 ));
495 }
496 };
497 self.create_episode(&CreateEpisodeParams {
498 podcast_id,
499 title,
500 script,
501 voice_id: p.voice_id,
502 description: p.description,
503 episode_number: p.episode_number,
504 season_number: p.season_number,
505 })
506 .await
507 }
508 "list_podcasts" => self.list_podcasts().await,
509 "get_podcast" => {
510 let podcast_id = match p.podcast_id {
511 Some(id) if !id.trim().is_empty() => id,
512 _ => {
513 return Ok(ToolResult::structured_error(
514 "MISSING_PARAM",
515 "podcast",
516 "'podcast_id' is required for get_podcast",
517 Some(vec!["podcast_id"]),
518 Some(json!({"action": "get_podcast", "podcast_id": "abc12345"})),
519 ));
520 }
521 };
522 self.get_podcast(&podcast_id).await
523 }
524 "delete_podcast" => {
525 let podcast_id = match p.podcast_id {
526 Some(id) if !id.trim().is_empty() => id,
527 _ => {
528 return Ok(ToolResult::structured_error(
529 "MISSING_PARAM",
530 "podcast",
531 "'podcast_id' is required for delete_podcast",
532 Some(vec!["podcast_id"]),
533 Some(json!({"action": "delete_podcast", "podcast_id": "abc12345"})),
534 ));
535 }
536 };
537 self.delete_podcast(&podcast_id).await
538 }
539 "delete_episode" => {
540 let podcast_id = match p.podcast_id {
541 Some(id) if !id.trim().is_empty() => id,
542 _ => {
543 return Ok(ToolResult::structured_error(
544 "MISSING_PARAM",
545 "podcast",
546 "'podcast_id' is required for delete_episode",
547 Some(vec!["podcast_id"]),
548 Some(
549 json!({"action": "delete_episode", "podcast_id": "abc12345", "episode_id": "xyz789"}),
550 ),
551 ));
552 }
553 };
554 let episode_id = match p.episode_id {
555 Some(id) if !id.trim().is_empty() => id,
556 _ => {
557 return Ok(ToolResult::structured_error(
558 "MISSING_PARAM",
559 "podcast",
560 "'episode_id' is required for delete_episode",
561 Some(vec!["episode_id"]),
562 Some(
563 json!({"action": "delete_episode", "podcast_id": "abc12345", "episode_id": "xyz789"}),
564 ),
565 ));
566 }
567 };
568 self.delete_episode(&podcast_id, &episode_id).await
569 }
570 other => Ok(ToolResult::structured_error(
571 "INVALID_ACTION",
572 "podcast",
573 &format!(
574 "Unknown action '{other}'. Use: create_podcast, create_episode, list_podcasts, get_podcast, delete_podcast, delete_episode"
575 ),
576 None,
577 Some(json!({"action": "list_podcasts"})),
578 )),
579 }
580 }
581}