Skip to main content

zeroclaw_channels/
clawdtalk.rs

1//! ClawdTalk voice channel - real-time voice calling via Telnyx SIP infrastructure.
2//!
3//! ClawdTalk (<https://clawdtalk.com>) provides AI-powered voice conversations
4//! using Telnyx's global SIP network for low-latency, high-quality calls.
5
6use async_trait::async_trait;
7use reqwest::Client;
8use serde::{Deserialize, Serialize};
9use tokio::sync::mpsc;
10use zeroclaw_api::channel::{Channel, ChannelMessage, SendMessage};
11
12pub use zeroclaw_config::scattered_types::ClawdTalkConfig;
13
14/// ClawdTalk channel configuration
15pub struct ClawdTalkChannel {
16    /// Telnyx API key for authentication
17    api_key: String,
18    /// Telnyx connection ID (SIP connection)
19    connection_id: String,
20    /// Phone number or SIP URI to call from
21    from_number: String,
22    /// Allowed destination numbers/patterns
23    allowed_destinations: Vec<String>,
24    /// The alias key under `[channels.clawdtalk.<alias>]` this handle is
25    /// bound to. Used for attribution.
26    alias: String,
27    /// HTTP client for Telnyx API
28    client: Client,
29}
30
31impl ClawdTalkChannel {
32    /// Create a new ClawdTalk channel
33    pub fn new(alias: impl Into<String>, config: ClawdTalkConfig) -> Self {
34        Self {
35            api_key: config.api_key,
36            connection_id: config.connection_id,
37            from_number: config.from_number,
38            allowed_destinations: config.allowed_destinations,
39            alias: alias.into(),
40            client: Client::builder()
41                .timeout(std::time::Duration::from_secs(30))
42                .build()
43                .unwrap_or_else(|_| Client::new()),
44        }
45    }
46
47    /// Telnyx API base URL
48    const TELNYX_API_URL: &'static str = "https://api.telnyx.com/v2";
49
50    /// Check if a destination is allowed
51    fn is_destination_allowed(&self, destination: &str) -> bool {
52        if self.allowed_destinations.is_empty() {
53            return true;
54        }
55        self.allowed_destinations.iter().any(|pattern| {
56            pattern == "*" || destination.starts_with(pattern) || pattern == destination
57        })
58    }
59
60    /// Initiate an outbound call via Telnyx
61    pub async fn initiate_call(
62        &self,
63        to: &str,
64        _prompt: Option<&str>,
65    ) -> anyhow::Result<CallSession> {
66        if !self.is_destination_allowed(to) {
67            anyhow::bail!("Destination {} is not in allowed list", to);
68        }
69
70        let request = CallRequest {
71            connection_id: self.connection_id.clone(),
72            to: to.to_string(),
73            from: self.from_number.clone(),
74            answering_machine_detection: Some(AnsweringMachineDetection {
75                mode: "premium".to_string(),
76            }),
77            webhook_url: None,
78            // AI voice settings via Telnyx Call Control
79            command_id: None,
80        };
81
82        let response = self
83            .client
84            .post(format!("{}/calls", Self::TELNYX_API_URL))
85            .header("Authorization", format!("Bearer {}", self.api_key))
86            .header("Content-Type", "application/json")
87            .json(&request)
88            .send()
89            .await?;
90
91        if !response.status().is_success() {
92            let error = response.text().await?;
93            anyhow::bail!("Failed to initiate call: {}", error);
94        }
95
96        let call_response: CallResponse = response.json().await?;
97
98        Ok(CallSession {
99            call_control_id: call_response.call_control_id,
100            call_leg_id: call_response.call_leg_id,
101            call_session_id: call_response.call_session_id,
102        })
103    }
104
105    /// Send audio or TTS to an active call
106    pub async fn speak(&self, call_control_id: &str, text: &str) -> anyhow::Result<()> {
107        let request = SpeakRequest {
108            payload: text.to_string(),
109            payload_type: "text".to_string(),
110            service_level: "premium".to_string(),
111            voice: "female".to_string(),
112            language: "en-US".to_string(),
113        };
114
115        let response = self
116            .client
117            .post(format!(
118                "{}/calls/{}/actions/speak",
119                Self::TELNYX_API_URL,
120                call_control_id
121            ))
122            .header("Authorization", format!("Bearer {}", self.api_key))
123            .header("Content-Type", "application/json")
124            .json(&request)
125            .send()
126            .await?;
127
128        if !response.status().is_success() {
129            let error = response.text().await?;
130            anyhow::bail!("Failed to speak: {}", error);
131        }
132
133        Ok(())
134    }
135
136    /// Hang up an active call
137    pub async fn hangup(&self, call_control_id: &str) -> anyhow::Result<()> {
138        let response = self
139            .client
140            .post(format!(
141                "{}/calls/{}/actions/hangup",
142                Self::TELNYX_API_URL,
143                call_control_id
144            ))
145            .header("Authorization", format!("Bearer {}", self.api_key))
146            .send()
147            .await?;
148
149        if !response.status().is_success() {
150            let error = response.text().await?;
151            ::zeroclaw_log::record!(
152                WARN,
153                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
154                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
155                &format!("Failed to hangup call: {}", error)
156            );
157        }
158
159        Ok(())
160    }
161
162    /// Start AI-powered conversation using Telnyx AI inference
163    pub async fn start_ai_conversation(
164        &self,
165        call_control_id: &str,
166        system_prompt: &str,
167        model: &str,
168    ) -> anyhow::Result<()> {
169        let request = AiConversationRequest {
170            system_prompt: system_prompt.to_string(),
171            model: model.to_string(),
172            voice_settings: VoiceSettings {
173                voice: "alloy".to_string(),
174                speed: 1.0,
175            },
176        };
177
178        let response = self
179            .client
180            .post(format!(
181                "{}/calls/{}/actions/ai_conversation",
182                Self::TELNYX_API_URL,
183                call_control_id
184            ))
185            .header("Authorization", format!("Bearer {}", self.api_key))
186            .header("Content-Type", "application/json")
187            .json(&request)
188            .send()
189            .await?;
190
191        if !response.status().is_success() {
192            let error = response.text().await?;
193            anyhow::bail!("Failed to start AI conversation: {}", error);
194        }
195
196        Ok(())
197    }
198}
199
200/// Active call session
201#[derive(Debug, Clone)]
202pub struct CallSession {
203    pub call_control_id: String,
204    pub call_leg_id: String,
205    pub call_session_id: String,
206}
207
208/// Telnyx call initiation request
209#[derive(Debug, Serialize)]
210struct CallRequest {
211    connection_id: String,
212    to: String,
213    from: String,
214    #[serde(skip_serializing_if = "Option::is_none")]
215    answering_machine_detection: Option<AnsweringMachineDetection>,
216    #[serde(skip_serializing_if = "Option::is_none")]
217    webhook_url: Option<String>,
218    #[serde(skip_serializing_if = "Option::is_none")]
219    command_id: Option<String>,
220}
221
222#[derive(Debug, Serialize)]
223struct AnsweringMachineDetection {
224    mode: String,
225}
226
227/// Telnyx call response
228#[derive(Debug, Deserialize)]
229struct CallResponse {
230    call_control_id: String,
231    call_leg_id: String,
232    call_session_id: String,
233}
234
235/// TTS speak request
236#[derive(Debug, Serialize)]
237struct SpeakRequest {
238    payload: String,
239    payload_type: String,
240    service_level: String,
241    voice: String,
242    language: String,
243}
244
245/// AI conversation request
246#[derive(Debug, Serialize)]
247struct AiConversationRequest {
248    system_prompt: String,
249    model: String,
250    voice_settings: VoiceSettings,
251}
252
253#[derive(Debug, Serialize)]
254struct VoiceSettings {
255    voice: String,
256    speed: f32,
257}
258
259impl ::zeroclaw_api::attribution::Attributable for ClawdTalkChannel {
260    fn role(&self) -> ::zeroclaw_api::attribution::Role {
261        ::zeroclaw_api::attribution::Role::Channel(
262            ::zeroclaw_api::attribution::ChannelKind::ClawdTalk,
263        )
264    }
265    fn alias(&self) -> &str {
266        &self.alias
267    }
268}
269
270#[async_trait]
271impl Channel for ClawdTalkChannel {
272    fn name(&self) -> &str {
273        "ClawdTalk"
274    }
275
276    async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
277        // For ClawdTalk, "send" initiates a call with the message as TTS
278        let session = self.initiate_call(&message.recipient, None).await?;
279
280        // Wait for call to be answered, then speak
281        tokio::time::sleep(std::time::Duration::from_secs(2)).await;
282
283        self.speak(&session.call_control_id, &message.content)
284            .await?;
285
286        // Give time for TTS to complete before hanging up
287        tokio::time::sleep(std::time::Duration::from_secs(1)).await;
288
289        self.hangup(&session.call_control_id).await?;
290
291        Ok(())
292    }
293
294    async fn listen(&self, tx: mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
295        // ClawdTalk listens for incoming calls via webhooks
296        // This would typically be handled by the gateway module
297        // For now, we signal that this channel is ready and wait indefinitely
298        ::zeroclaw_log::record!(
299            INFO,
300            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
301            "channel listening for incoming calls"
302        );
303
304        // Keep the listener alive
305        loop {
306            tokio::time::sleep(std::time::Duration::from_secs(60)).await;
307
308            // Check if channel is still open
309            if tx.is_closed() {
310                break;
311            }
312        }
313
314        Ok(())
315    }
316
317    async fn health_check(&self) -> bool {
318        // Verify API key by checking Telnyx number configuration
319        let response = self
320            .client
321            .get(format!("{}/phone_numbers", Self::TELNYX_API_URL))
322            .header("Authorization", format!("Bearer {}", self.api_key))
323            .send()
324            .await;
325
326        match response {
327            Ok(resp) => resp.status().is_success(),
328            Err(e) => {
329                ::zeroclaw_log::record!(
330                    WARN,
331                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
332                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
333                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
334                    "health check failed"
335                );
336                false
337            }
338        }
339    }
340}
341
342/// Webhook event from Telnyx for incoming calls
343#[derive(Debug, Deserialize)]
344pub struct TelnyxWebhookEvent {
345    pub data: TelnyxWebhookData,
346}
347
348#[derive(Debug, Deserialize)]
349pub struct TelnyxWebhookData {
350    pub event_type: String,
351    pub payload: TelnyxCallPayload,
352}
353
354#[derive(Debug, Deserialize)]
355pub struct TelnyxCallPayload {
356    pub call_control_id: Option<String>,
357    pub call_leg_id: Option<String>,
358    pub call_session_id: Option<String>,
359    pub direction: Option<String>,
360    pub from: Option<String>,
361    pub to: Option<String>,
362    pub state: Option<String>,
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    fn test_config() -> ClawdTalkConfig {
370        ClawdTalkConfig {
371            enabled: true,
372            api_key: "test-key".to_string(),
373            connection_id: "test-connection".to_string(),
374            from_number: "+15551234567".to_string(),
375            allowed_destinations: vec!["+1555".to_string()],
376            webhook_secret: None,
377            excluded_tools: vec![],
378        }
379    }
380
381    #[test]
382    fn creates_channel() {
383        let channel = ClawdTalkChannel::new("testbot", test_config());
384        assert_eq!(channel.name(), "ClawdTalk");
385    }
386
387    #[test]
388    fn destination_allowed_exact_match() {
389        let channel = ClawdTalkChannel::new("testbot", test_config());
390        assert!(channel.is_destination_allowed("+15559876543"));
391        assert!(!channel.is_destination_allowed("+14449876543"));
392    }
393
394    #[test]
395    fn destination_allowed_wildcard() {
396        let mut config = test_config();
397        config.allowed_destinations = vec!["*".to_string()];
398        let channel = ClawdTalkChannel::new("testbot", config);
399        assert!(channel.is_destination_allowed("+15559876543"));
400        assert!(channel.is_destination_allowed("+14449876543"));
401    }
402
403    #[test]
404    fn destination_allowed_empty_means_all() {
405        let mut config = test_config();
406        config.allowed_destinations = vec![];
407        let channel = ClawdTalkChannel::new("testbot", config);
408        assert!(channel.is_destination_allowed("+15559876543"));
409        assert!(channel.is_destination_allowed("+14449876543"));
410    }
411
412    #[test]
413    fn webhook_event_deserializes() {
414        let json = r#"{
415            "data": {
416                "event_type": "call.initiated",
417                "payload": {
418                    "call_control_id": "call-123",
419                    "call_leg_id": "leg-123",
420                    "call_session_id": "session-123",
421                    "direction": "incoming",
422                    "from": "+15551112222",
423                    "to": "+15553334444",
424                    "state": "ringing"
425                }
426            }
427        }"#;
428
429        let event: TelnyxWebhookEvent = serde_json::from_str(json).unwrap();
430        assert_eq!(event.data.event_type, "call.initiated");
431        assert_eq!(
432            event.data.payload.call_control_id,
433            Some("call-123".to_string())
434        );
435        assert_eq!(event.data.payload.from, Some("+15551112222".to_string()));
436    }
437}