Skip to main content

codetether_agent/tool/
podcast.rs

1//! Podcast Tool - Create and manage AI-generated podcasts via Qwen TTS.
2//!
3//! Lets the agent programmatically create podcasts, generate TTS episodes
4//! from scripts using cloned voices, manage feeds, and produce RSS-ready
5//! podcast content — all from natural language instructions.
6
7use 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); // Episodes can take a while
15
16fn 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}