Skip to main content

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