1use async_trait::async_trait;
14use serde_json::json;
15use std::collections::HashMap;
16use std::sync::Arc;
17use zeroclaw_api::channel::{Channel, SendMessage};
18use zeroclaw_api::tool::{Tool, ToolResult};
19use zeroclaw_config::policy::SecurityPolicy;
20
21pub type PerToolChannelHandle =
24 Arc<parking_lot::RwLock<std::collections::HashMap<String, Arc<dyn Channel>>>>;
25
26pub struct ChannelSendTool {
35 security: Arc<SecurityPolicy>,
36 channel_map: PerToolChannelHandle,
37 default_targets: Arc<parking_lot::RwLock<HashMap<String, String>>>,
38}
39
40impl ChannelSendTool {
41 pub fn new(
42 security: Arc<SecurityPolicy>,
43 channel_map: PerToolChannelHandle,
44 default_targets: Arc<parking_lot::RwLock<HashMap<String, String>>>,
45 ) -> Self {
46 Self {
47 security,
48 channel_map,
49 default_targets,
50 }
51 }
52}
53
54#[async_trait]
55impl Tool for ChannelSendTool {
56 fn name(&self) -> &str {
57 "channel_send"
58 }
59
60 fn description(&self) -> &str {
61 "Send a message through a configured messaging channel (e.g. telegram, slack, discord). Use when the agent needs to deliver a message to an external channel."
62 }
63
64 fn parameters_schema(&self) -> serde_json::Value {
65 json!({
66 "type": "object",
67 "properties": {
68 "channel": {
69 "type": "string",
70 "description": "Composite channel key (e.g. telegram.default, telegram.prod, slack.prod) or bare type name (telegram, slack, discord, mattermost, signal, matrix, irc). Bare names resolve to <type>.default."
71 },
72 "to": {
73 "type": "string",
74 "description": "Optional recipient ID. When omitted, uses the configured default_target for the channel. When provided, must match the configured default_target."
75 },
76 "body": {
77 "type": "string",
78 "description": "Message content to send"
79 }
80 },
81 "required": ["channel", "body"]
82 })
83 }
84
85 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
86 if !self.security.can_act() {
87 return Ok(ToolResult {
88 success: false,
89 output: String::new(),
90 error: Some("Action blocked: autonomy is read-only".into()),
91 });
92 }
93
94 if !self.security.record_action() {
95 return Ok(ToolResult {
96 success: false,
97 output: String::new(),
98 error: Some("Action blocked: rate limit exceeded".into()),
99 });
100 }
101
102 let channel_name = args
103 .get("channel")
104 .and_then(|v| v.as_str())
105 .map(str::trim)
106 .filter(|v| !v.is_empty())
107 .ok_or_else(|| anyhow::Error::msg("Missing 'channel' parameter"))?
108 .to_string();
109
110 let body = args
111 .get("body")
112 .and_then(|v| v.as_str())
113 .map(str::trim)
114 .filter(|v| !v.is_empty())
115 .ok_or_else(|| anyhow::Error::msg("Missing 'body' parameter"))?
116 .to_string();
117
118 let provided_to = args
119 .get("to")
120 .and_then(|v| v.as_str())
121 .map(str::trim)
122 .filter(|v| !v.is_empty())
123 .map(str::to_string);
124
125 let (channel, resolved_key) = {
126 let channel_map = self.channel_map.read();
127
128 let channel = channel_map.get(&channel_name).cloned();
130 let resolved_key = if channel.is_some() {
131 channel_name.clone()
132 } else {
133 String::new()
134 };
135
136 let channel = channel.or_else(|| {
138 if !channel_name.contains('.') {
139 let default_key = format!("{channel_name}.default");
140 channel_map.get(&default_key).cloned()
141 } else {
142 None
143 }
144 });
145 let resolved_key = if channel.is_some() && resolved_key.is_empty() {
146 if !channel_name.contains('.') {
147 format!("{channel_name}.default")
148 } else {
149 resolved_key
150 }
151 } else {
152 resolved_key
153 };
154
155 let channel = channel.or_else(|| {
157 if let Some(bare) = channel_name.split('.').next() {
158 if bare != channel_name {
159 let default_key = format!("{bare}.default");
160 channel_map.get(&default_key).cloned()
161 } else {
162 None
163 }
164 } else {
165 None
166 }
167 });
168 let resolved_key = if channel.is_some() && resolved_key.is_empty() {
169 if let Some(bare) = channel_name.split('.').next() {
170 if bare != channel_name {
171 format!("{bare}.default")
172 } else {
173 resolved_key
174 }
175 } else {
176 resolved_key
177 }
178 } else {
179 resolved_key
180 };
181
182 let channel = channel.ok_or_else(|| {
183 let available: Vec<String> = channel_map.keys().cloned().collect();
187 anyhow::Error::msg(format!(
188 "Channel '{}' not found. Available channels: {:?}",
189 channel_name, available
190 ))
191 })?;
192
193 (channel, resolved_key)
194 };
195
196 let to = {
198 let targets = self.default_targets.read();
199
200 let target_key = if !resolved_key.contains('.') {
205 let composite = format!("{resolved_key}.default");
206 if !targets.contains_key(&resolved_key) && targets.contains_key(&composite) {
207 composite
208 } else {
209 resolved_key.clone()
210 }
211 } else {
212 resolved_key.clone()
213 };
214
215 let configured_target = targets.get(&target_key).cloned();
216
217 match (provided_to, configured_target) {
218 (Some(provided), Some(configured)) => {
219 if provided != configured {
220 return Ok(ToolResult {
221 success: false,
222 output: String::new(),
223 error: Some(format!(
224 "Recipient '{}' does not match the configured default_target '{}' for channel '{}'. Arbitrary recipients are not allowed.",
225 provided, configured, target_key
226 )),
227 });
228 }
229 provided
230 }
231 (None, Some(configured)) => {
232 configured
234 }
235 (Some(provided), None) => {
236 return Ok(ToolResult {
237 success: false,
238 output: String::new(),
239 error: Some(format!(
240 "No default_target configured for channel '{}'. Cannot send to arbitrary recipient '{}'.",
241 target_key, provided
242 )),
243 });
244 }
245 (None, None) => {
246 return Ok(ToolResult {
247 success: false,
248 output: String::new(),
249 error: Some(format!(
250 "No default_target configured for channel '{}'. Either configure a default_target or provide a matching recipient.",
251 target_key
252 )),
253 });
254 }
255 }
256 };
257
258 let message = SendMessage::new(body, &to);
259
260 channel.send(&message).await.map_err(|e| {
261 anyhow::Error::msg(format!(
262 "Failed to send message through '{}': {}",
263 channel.name(),
264 e
265 ))
266 })?;
267
268 Ok(ToolResult {
269 success: true,
270 output: format!(
271 "Message sent successfully to channel '{}', recipient '{}'",
272 resolved_key, to
273 ),
274 error: None,
275 })
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use std::collections::HashMap;
283 use zeroclaw_api::attribution::{Attributable, ChannelKind, Role};
284
285 struct StubChannel {
287 name: String,
288 }
289
290 impl StubChannel {
291 fn new(name: &str) -> Self {
292 Self {
293 name: name.to_string(),
294 }
295 }
296 }
297
298 impl Attributable for StubChannel {
299 fn role(&self) -> Role {
300 Role::Channel(ChannelKind::Webhook)
301 }
302 fn alias(&self) -> &str {
303 "test"
304 }
305 }
306
307 #[async_trait]
308 impl Channel for StubChannel {
309 fn name(&self) -> &str {
310 &self.name
311 }
312 async fn send(&self, _message: &SendMessage) -> anyhow::Result<()> {
313 Ok(())
314 }
315 async fn listen(
316 &self,
317 _tx: tokio::sync::mpsc::Sender<zeroclaw_api::channel::ChannelMessage>,
318 ) -> anyhow::Result<()> {
319 Ok(())
320 }
321 }
322
323 fn make_tool(
324 handles: PerToolChannelHandle,
325 default_targets: HashMap<String, String>,
326 ) -> ChannelSendTool {
327 ChannelSendTool::new(
328 Arc::new(SecurityPolicy::default()),
329 handles,
330 Arc::new(parking_lot::RwLock::new(default_targets)),
331 )
332 }
333
334 #[test]
335 fn tool_name_and_description() {
336 let tool = make_tool(
337 Arc::new(parking_lot::RwLock::new(HashMap::new())),
338 HashMap::new(),
339 );
340 assert_eq!(tool.name(), "channel_send");
341 assert!(!tool.description().is_empty());
342 }
343
344 #[test]
345 fn parameter_schema_has_required_fields() {
346 let tool = make_tool(
347 Arc::new(parking_lot::RwLock::new(HashMap::new())),
348 HashMap::new(),
349 );
350 let schema = tool.parameters_schema();
351 let required = schema.get("required").unwrap().as_array().unwrap();
352 assert!(required.iter().any(|v| v.as_str() == Some("channel")));
353 assert!(required.iter().any(|v| v.as_str() == Some("body")));
354 assert!(!required.iter().any(|v| v.as_str() == Some("to")));
356 }
357
358 #[test]
359 fn spec_matches_metadata() {
360 let tool = make_tool(
361 Arc::new(parking_lot::RwLock::new(HashMap::new())),
362 HashMap::new(),
363 );
364 let spec = tool.spec();
365 assert_eq!(spec.name, tool.name());
366 assert_eq!(spec.description, tool.description());
367 }
368
369 #[tokio::test]
370 async fn empty_channel_map_returns_error_with_available_list() {
371 let tool = make_tool(
372 Arc::new(parking_lot::RwLock::new(HashMap::new())),
373 HashMap::new(),
374 );
375 let result = tool
376 .execute(serde_json::json!({
377 "channel": "telegram.default",
378 "body": "hello"
379 }))
380 .await;
381 let err = result.unwrap_err().to_string();
382 assert!(err.contains("not found"));
383 assert!(err.contains("Available channels"));
384 }
385
386 #[tokio::test]
387 async fn composite_key_resolves_exact_match() {
388 let map: PerToolChannelHandle = Arc::new(parking_lot::RwLock::new(HashMap::new()));
389 map.write().insert(
390 "telegram.prod".to_string(),
391 Arc::new(StubChannel::new("telegram.prod")),
392 );
393 let mut targets = HashMap::new();
394 targets.insert("telegram.prod".to_string(), "chat_482910".to_string());
395 let tool = make_tool(map, targets);
396 let result = tool
397 .execute(serde_json::json!({
398 "channel": "telegram.prod",
399 "to": "chat_482910",
400 "body": "hello"
401 }))
402 .await;
403 assert!(result.unwrap().success);
404 }
405
406 #[tokio::test]
407 async fn bare_type_key_resolves_to_default() {
408 let map: PerToolChannelHandle = Arc::new(parking_lot::RwLock::new(HashMap::new()));
409 map.write().insert(
410 "telegram.default".to_string(),
411 Arc::new(StubChannel::new("telegram.default")),
412 );
413 let mut targets = HashMap::new();
414 targets.insert("telegram.default".to_string(), "chat_482910".to_string());
415 let tool = make_tool(map, targets);
416 let result = tool
417 .execute(serde_json::json!({
418 "channel": "telegram",
419 "to": "chat_482910",
420 "body": "hello"
421 }))
422 .await;
423 assert!(result.unwrap().success);
424 }
425
426 #[tokio::test]
427 async fn aliased_key_falls_back_to_default() {
428 let map: PerToolChannelHandle = Arc::new(parking_lot::RwLock::new(HashMap::new()));
429 map.write().insert(
430 "telegram.default".to_string(),
431 Arc::new(StubChannel::new("telegram.default")),
432 );
433 let mut targets = HashMap::new();
434 targets.insert("telegram.default".to_string(), "chat_482910".to_string());
435 let tool = make_tool(map, targets);
436 let result = tool
437 .execute(serde_json::json!({
438 "channel": "telegram.prod",
439 "to": "chat_482910",
440 "body": "hello"
441 }))
442 .await;
443 assert!(result.unwrap().success);
444 }
445
446 #[tokio::test]
448 async fn configured_target_is_accepted() {
449 let map: PerToolChannelHandle = Arc::new(parking_lot::RwLock::new(HashMap::new()));
450 map.write().insert(
451 "telegram.default".to_string(),
452 Arc::new(StubChannel::new("telegram.default")),
453 );
454 let mut targets = HashMap::new();
455 targets.insert("telegram.default".to_string(), "chat_123".to_string());
456 let tool = make_tool(map, targets);
457 let result = tool
458 .execute(serde_json::json!({
459 "channel": "telegram",
460 "to": "chat_123",
461 "body": "hello"
462 }))
463 .await;
464 assert!(result.unwrap().success);
465 }
466
467 #[tokio::test]
469 async fn arbitrary_recipient_is_rejected() {
470 let map: PerToolChannelHandle = Arc::new(parking_lot::RwLock::new(HashMap::new()));
471 map.write().insert(
472 "telegram.default".to_string(),
473 Arc::new(StubChannel::new("telegram.default")),
474 );
475 let mut targets = HashMap::new();
476 targets.insert("telegram.default".to_string(), "chat_123".to_string());
477 let tool = make_tool(map, targets);
478 let result = tool
479 .execute(serde_json::json!({
480 "channel": "telegram",
481 "to": "chat_999_evil",
482 "body": "hello"
483 }))
484 .await;
485 let result = result.unwrap();
486 assert!(!result.success);
487 assert!(result.error.as_ref().unwrap().contains("does not match"));
488 assert!(result.error.as_ref().unwrap().contains("chat_123"));
489 }
490
491 #[tokio::test]
493 async fn omitted_to_resolves_to_configured_default() {
494 let map: PerToolChannelHandle = Arc::new(parking_lot::RwLock::new(HashMap::new()));
495 map.write().insert(
496 "telegram.default".to_string(),
497 Arc::new(StubChannel::new("telegram.default")),
498 );
499 let mut targets = HashMap::new();
500 targets.insert("telegram.default".to_string(), "chat_123".to_string());
501 let tool = make_tool(map, targets);
502 let result = tool
503 .execute(serde_json::json!({
504 "channel": "telegram",
505 "body": "hello"
506 }))
507 .await;
508 assert!(result.unwrap().success);
509 }
510
511 #[tokio::test]
513 async fn no_default_target_and_no_to_returns_error() {
514 let map: PerToolChannelHandle = Arc::new(parking_lot::RwLock::new(HashMap::new()));
515 map.write().insert(
516 "telegram.default".to_string(),
517 Arc::new(StubChannel::new("telegram.default")),
518 );
519 let tool = make_tool(map, HashMap::new());
520 let result = tool
521 .execute(serde_json::json!({
522 "channel": "telegram",
523 "body": "hello"
524 }))
525 .await;
526 let result = result.unwrap();
527 assert!(!result.success);
528 assert!(
529 result
530 .error
531 .as_ref()
532 .unwrap()
533 .contains("No default_target configured")
534 );
535 }
536
537 #[tokio::test]
539 async fn no_default_target_with_to_returns_error() {
540 let map: PerToolChannelHandle = Arc::new(parking_lot::RwLock::new(HashMap::new()));
541 map.write().insert(
542 "telegram.default".to_string(),
543 Arc::new(StubChannel::new("telegram.default")),
544 );
545 let tool = make_tool(map, HashMap::new());
546 let result = tool
547 .execute(serde_json::json!({
548 "channel": "telegram",
549 "to": "chat_999",
550 "body": "hello"
551 }))
552 .await;
553 let result = result.unwrap();
554 assert!(!result.success);
555 assert!(
556 result
557 .error
558 .as_ref()
559 .unwrap()
560 .contains("Cannot send to arbitrary recipient")
561 );
562 }
563
564 #[tokio::test]
570 async fn bare_singleton_key_resolves_target_from_composite() {
571 let map: PerToolChannelHandle = Arc::new(parking_lot::RwLock::new(HashMap::new()));
572 map.write().insert(
573 "telegram".to_string(),
574 Arc::new(StubChannel::new("telegram")),
575 );
576 map.write().insert(
577 "telegram.default".to_string(),
578 Arc::new(StubChannel::new("telegram.default")),
579 );
580 let mut targets = HashMap::new();
581 targets.insert("telegram.default".to_string(), "chat_123".to_string());
582 let tool = make_tool(map, targets);
583 let result = tool
584 .execute(serde_json::json!({
585 "channel": "telegram",
586 "body": "hello"
587 }))
588 .await;
589 assert!(result.unwrap().success);
590 }
591}