1use 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
14pub struct ClawdTalkChannel {
16 api_key: String,
18 connection_id: String,
20 from_number: String,
22 allowed_destinations: Vec<String>,
24 alias: String,
27 client: Client,
29}
30
31impl ClawdTalkChannel {
32 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 const TELNYX_API_URL: &'static str = "https://api.telnyx.com/v2";
49
50 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 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 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 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 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 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#[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#[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#[derive(Debug, Deserialize)]
229struct CallResponse {
230 call_control_id: String,
231 call_leg_id: String,
232 call_session_id: String,
233}
234
235#[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#[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 let session = self.initiate_call(&message.recipient, None).await?;
279
280 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
282
283 self.speak(&session.call_control_id, &message.content)
284 .await?;
285
286 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 ::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 loop {
306 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
307
308 if tx.is_closed() {
310 break;
311 }
312 }
313
314 Ok(())
315 }
316
317 async fn health_check(&self) -> bool {
318 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#[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}