Skip to main content

zeroclaw_runtime/
channel_targets.rs

1//! Build the configured channel targets section for system prompt injection.
2//!
3//! Returns `Some(string)` if any channels have `default_target` set, `None` otherwise.
4
5use std::collections::HashMap;
6use zeroclaw_config::schema::Config;
7
8/// Scans a single channel-type map (`HashMap<String, T>`) for enabled instances
9/// that have a `default_target` set, and appends `(composite_key, target)` pairs
10/// to the given entries vector.
11///
12/// The composite key is `<channel_type>.<alias>` (e.g. `telegram.default`).
13///
14/// **Why a macro:** `ChannelsConfig` stores each channel type as a separate
15/// `HashMap<String, SpecificConfig>` field — there is no common trait that
16/// exposes `enabled`, `default_target`, and a type name uniformly. A macro
17/// avoids duplicating the same 7-line `for` loop 9 times and ensures that
18/// adding a new channel type only requires one additional invocation.
19macro_rules! scan_channel_map {
20    ($entries:expr, push, $channel_type:ident, $map:expr) => {
21        for (alias, cfg) in $map {
22            if cfg.enabled
23                && let Some(ref t) = cfg.default_target
24            {
25                let key = if alias.is_empty() {
26                    stringify!($channel_type).to_string()
27                } else {
28                    format!(concat!(stringify!($channel_type), ".{}"), alias)
29                };
30                $entries.push((key, t.clone()));
31            }
32        }
33    };
34    ($map_out:expr, insert, $channel_type:ident, $map:expr) => {
35        for (alias, cfg) in $map {
36            if cfg.enabled
37                && let Some(ref t) = cfg.default_target
38            {
39                let key = if alias.is_empty() {
40                    stringify!($channel_type).to_string()
41                } else {
42                    format!(concat!(stringify!($channel_type), ".{}"), alias)
43                };
44                $map_out.insert(key, t.clone());
45            }
46        }
47    };
48}
49
50pub fn build_channel_targets(config: &Config) -> Option<String> {
51    let mut entries: Vec<(String, String)> = Vec::new();
52
53    scan_channel_map!(entries, push, telegram, &config.channels.telegram);
54    scan_channel_map!(entries, push, discord, &config.channels.discord);
55    scan_channel_map!(entries, push, slack, &config.channels.slack);
56    scan_channel_map!(entries, push, mattermost, &config.channels.mattermost);
57    scan_channel_map!(entries, push, matrix, &config.channels.matrix);
58    scan_channel_map!(entries, push, irc, &config.channels.irc);
59    scan_channel_map!(entries, push, signal, &config.channels.signal);
60    scan_channel_map!(entries, push, whatsapp, &config.channels.whatsapp);
61    scan_channel_map!(entries, push, lark, &config.channels.lark);
62
63    if entries.is_empty() {
64        return None;
65    }
66
67    let mut out = String::new();
68    out.push_str("## Configured Channel Targets\n\n");
69    out.push_str("Use these recipients when sending messages via the `channel_send` tool. For each entry, use the composite key (e.g. `telegram.default`) as the `channel` parameter and the target as the `to` parameter.\n\n");
70    for (channel, target) in &entries {
71        out.push_str(&format!("- {channel}: {target}\n"));
72    }
73    Some(out)
74}
75
76/// Build a map of composite channel key → configured default_target.
77///
78/// Used by `channel_send` to enforce that the model can only send to
79/// operator-configured recipients. Returns an empty map when no channels
80/// have `default_target` set.
81pub fn build_default_targets_map(config: &Config) -> HashMap<String, String> {
82    let mut map = HashMap::new();
83
84    scan_channel_map!(map, insert, telegram, &config.channels.telegram);
85    scan_channel_map!(map, insert, discord, &config.channels.discord);
86    scan_channel_map!(map, insert, slack, &config.channels.slack);
87    scan_channel_map!(map, insert, mattermost, &config.channels.mattermost);
88    scan_channel_map!(map, insert, matrix, &config.channels.matrix);
89    scan_channel_map!(map, insert, irc, &config.channels.irc);
90    scan_channel_map!(map, insert, signal, &config.channels.signal);
91    scan_channel_map!(map, insert, whatsapp, &config.channels.whatsapp);
92    scan_channel_map!(map, insert, lark, &config.channels.lark);
93
94    map
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use std::collections::HashMap;
101    use zeroclaw_config::schema::{
102        ChannelsConfig, Config, DiscordConfig, LarkConfig, TelegramConfig,
103    };
104
105    fn make_config() -> Config {
106        Config {
107            channels: ChannelsConfig::default(),
108            ..Config::default()
109        }
110    }
111
112    #[test]
113    fn returns_none_when_no_channels_configured() {
114        let config = make_config();
115        assert!(build_channel_targets(&config).is_none());
116    }
117
118    #[test]
119    fn returns_none_when_channels_have_no_default_target() {
120        let mut config = make_config();
121        let mut telegram = HashMap::new();
122        telegram.insert(
123            "default".to_string(),
124            TelegramConfig {
125                enabled: true,
126                bot_token: "test-token".to_string(),
127                ..TelegramConfig::default()
128            },
129        );
130        config.channels.telegram = telegram;
131        assert!(build_channel_targets(&config).is_none());
132    }
133
134    #[test]
135    fn returns_none_when_channel_disabled() {
136        let mut config = make_config();
137        let mut telegram = HashMap::new();
138        telegram.insert(
139            "default".to_string(),
140            TelegramConfig {
141                enabled: false,
142                bot_token: "test-token".to_string(),
143                default_target: Some("chat_123".to_string()),
144                ..TelegramConfig::default()
145            },
146        );
147        config.channels.telegram = telegram;
148        assert!(build_channel_targets(&config).is_none());
149    }
150
151    #[test]
152    fn returns_single_telegram_target() {
153        let mut config = make_config();
154        let mut telegram = HashMap::new();
155        telegram.insert(
156            "default".to_string(),
157            TelegramConfig {
158                enabled: true,
159                bot_token: "test-token".to_string(),
160                default_target: Some("chat_123".to_string()),
161                ..TelegramConfig::default()
162            },
163        );
164        config.channels.telegram = telegram;
165
166        let result = build_channel_targets(&config).unwrap();
167        assert!(result.contains("## Configured Channel Targets"));
168        assert!(result.contains("telegram.default: chat_123"));
169    }
170
171    #[test]
172    fn returns_multiple_channels_of_different_types() {
173        let mut config = make_config();
174
175        let mut telegram = HashMap::new();
176        telegram.insert(
177            "default".to_string(),
178            TelegramConfig {
179                enabled: true,
180                bot_token: "test-token".to_string(),
181                default_target: Some("tg_chat_1".to_string()),
182                ..TelegramConfig::default()
183            },
184        );
185        config.channels.telegram = telegram;
186
187        let mut discord = HashMap::new();
188        discord.insert(
189            "prod".to_string(),
190            DiscordConfig {
191                enabled: true,
192                bot_token: "test-token".to_string(),
193                default_target: Some("discord_chan_2".to_string()),
194                ..DiscordConfig::default()
195            },
196        );
197        config.channels.discord = discord;
198
199        let result = build_channel_targets(&config).unwrap();
200        assert!(result.contains("telegram.default: tg_chat_1"));
201        assert!(result.contains("discord.prod: discord_chan_2"));
202    }
203
204    #[test]
205    fn includes_lark_channel() {
206        let mut config = make_config();
207        let mut lark = HashMap::new();
208        lark.insert(
209            "default".to_string(),
210            LarkConfig {
211                enabled: true,
212                app_id: "test-app".to_string(),
213                app_secret: "test-secret".to_string(),
214                default_target: Some("lark_chat_42".to_string()),
215                ..LarkConfig::default()
216            },
217        );
218        config.channels.lark = lark;
219
220        let result = build_channel_targets(&config).unwrap();
221        assert!(result.contains("lark.default: lark_chat_42"));
222    }
223
224    #[test]
225    fn output_contains_usage_instructions() {
226        let mut config = make_config();
227        let mut telegram = HashMap::new();
228        telegram.insert(
229            "default".to_string(),
230            TelegramConfig {
231                enabled: true,
232                bot_token: "test-token".to_string(),
233                default_target: Some("chat_123".to_string()),
234                ..TelegramConfig::default()
235            },
236        );
237        config.channels.telegram = telegram;
238
239        let result = build_channel_targets(&config).unwrap();
240        assert!(result.contains("channel_send"));
241        assert!(result.contains("composite key"));
242    }
243
244    #[test]
245    fn default_targets_map_empty_when_no_channels() {
246        let config = make_config();
247        let map = build_default_targets_map(&config);
248        assert!(map.is_empty());
249    }
250
251    #[test]
252    fn default_targets_map_contains_configured_targets() {
253        let mut config = make_config();
254        let mut telegram = HashMap::new();
255        telegram.insert(
256            "default".to_string(),
257            TelegramConfig {
258                enabled: true,
259                bot_token: "test-token".to_string(),
260                default_target: Some("chat_123".to_string()),
261                ..TelegramConfig::default()
262            },
263        );
264        config.channels.telegram = telegram;
265
266        let map = build_default_targets_map(&config);
267        assert_eq!(map.get("telegram.default"), Some(&"chat_123".to_string()));
268    }
269}