Skip to main content

zeroclaw_tools/
channel_send.rs

1//! Channel send tool — lets the agent deliver messages to configured channels.
2//!
3//! Wraps `Channel::send()` so the daemon/CLI agent loop can push messages
4//! into Telegram, Slack, Discord, etc. The agent receives configured channel
5//! names and target IDs from system prompt injection.
6//!
7//! **Security:** The `to` parameter is optional. When omitted, the configured
8//! `default_target` for the resolved channel key is used. When provided, it
9//! must match the configured `default_target` — arbitrary recipients are
10//! rejected. This ensures the model can only send to operator-configured
11//! destinations.
12
13use 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
21/// Per-tool channel-map handle — matches `zeroclaw_runtime::tools::PerToolChannelHandle`.
22/// Defined locally so `zeroclaw-tools` doesn't depend on `zeroclaw-runtime`.
23pub type PerToolChannelHandle =
24    Arc<parking_lot::RwLock<std::collections::HashMap<String, Arc<dyn Channel>>>>;
25
26/// Tool that sends a message through a configured channel.
27///
28/// Parameters:
29/// - `channel`: Composite channel key (`telegram.default`, `telegram.prod`, etc.) or bare
30///   type name (`telegram`, `slack`). Bare names resolve to `<type>.default`.
31/// - `to`: Optional recipient/target ID. When omitted, uses the configured `default_target`.
32///   When provided, must match the configured `default_target` for the resolved channel.
33/// - `body`: Message content to send
34pub 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            // 1. exact composite key
129            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            // 2. bare type → <type>.default
137            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            // 3. aliased → fallback to <type>.default
156            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                // Intentional: enumerating available channel keys in the error
184                // helps the agent correct a wrong channel name. These keys are
185                // operator-configured, not secrets — safe to expose in ToolResult.error.
186                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        // Resolve the recipient: validate against configured default_target.
197        let to = {
198            let targets = self.default_targets.read();
199
200            // Canonicalize the resolved key for target enforcement: if the
201            // channel map contained a bare singleton key (e.g. "telegram") but
202            // the target map only has the composite key (e.g. "telegram.default"),
203            // fall back to the composite key so the configured target is found.
204            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                    // Use the configured default_target.
233                    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    /// Stub channel for tests — records send calls.
286    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        // 'to' is no longer required
355        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    /// Regression: configured/default target is accepted.
447    #[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    /// Regression: arbitrary different recipient is rejected before Channel::send().
468    #[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    /// Regression: omitted `to` resolves to configured default_target.
492    #[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    /// Regression: no default_target configured and no `to` provided — error.
512    #[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    /// Regression: no default_target configured but `to` provided — error (arbitrary recipient).
538    #[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    /// Regression: bare singleton channel key resolves target from composite key.
565    /// When the channel map contains both "telegram" (bare singleton) and
566    /// "telegram.default" (composite), but the target map only has
567    /// "telegram.default", a send with channel="telegram" and omitted "to"
568    /// must succeed using the configured target.
569    #[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}