1use 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
15pub struct ClawdTalkChannel {
17 api_key: String,
19 connection_id: String,
21 from_number: String,
23 allowed_destinations: Vec<String>,
25 client: Client,
27 webhook_secret: Option<String>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
33pub struct ClawdTalkConfig {
34 pub api_key: String,
36 pub connection_id: String,
38 pub from_number: String,
40 #[serde(default)]
42 pub allowed_destinations: Vec<String>,
43 #[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 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 const TELNYX_API_URL: &'static str = "https://api.telnyx.com/v2";
75
76 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 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 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 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 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 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#[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#[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#[derive(Debug, Deserialize)]
250struct CallResponse {
251 call_control_id: String,
252 call_leg_id: String,
253 call_session_id: String,
254}
255
256#[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#[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 let session = self.initiate_call(&message.recipient, None).await?;
289
290 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
292
293 self.speak(&session.call_control_id, &message.content)
294 .await?;
295
296 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 tracing::info!("ClawdTalk channel listening for incoming calls");
309
310 loop {
312 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
313
314 if tx.is_closed() {
316 break;
317 }
318 }
319
320 Ok(())
321 }
322
323 async fn health_check(&self) -> bool {
324 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#[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}