1pub mod approvals;
26pub mod bridge;
27pub mod platform;
28pub mod platforms;
29pub mod render;
30
31pub use bridge::{ConnectBridge, ConnectContext, SessionKey};
32pub use platform::{
33 Button, CallbackQuery, Capabilities, Inbound, InboundMessage, MessageRef, OutboundMessage,
34 Platform, PlatformError, PlatformResult, ReplyCtx,
35};
36
37use std::path::PathBuf;
38use std::sync::Arc;
39
40use tokio::sync::mpsc;
41
42use bamboo_config::ConnectPlatformConfig;
43use bamboo_llm::Config;
44
45pub struct ConnectManager {
51 tasks: Vec<tokio::task::JoinHandle<()>>,
52}
53
54impl ConnectManager {
55 pub async fn start(
60 ctx: ConnectContext,
61 config_snapshot: &Config,
62 data_dir: Option<PathBuf>,
63 ) -> Self {
64 let map_path = data_dir.map(|dir| dir.join("connect_sessions.json"));
65 let bridge = Arc::new(ConnectBridge::new(ctx, map_path));
66 bridge.load_session_map().await;
67
68 let mut tasks = Vec::new();
69 let start_ok = multi_bot_guard(&config_snapshot.connect.platforms);
70 for (index, platform_cfg) in config_snapshot.connect.platforms.iter().enumerate() {
71 match platform_cfg.platform_type.as_str() {
72 "telegram" => {
73 let token = platform_cfg.token.clone().unwrap_or_default();
74 if token.trim().is_empty() {
75 tracing::warn!(
76 "connect: telegram platform configured without a token; skipping"
77 );
78 continue;
79 }
80 if !start_ok[index] {
91 tracing::warn!(
92 "connect: multiple telegram platform entries are configured; only \
93 the FIRST is started. A second telegram bot on this instance would \
94 collide with the first on the same session-routing key \
95 (`telegram:<chat_id>:<user_id>`), silently mixing sessions for any \
96 user who messages both bots. Remove the extra entry, or track \
97 issue #454 for per-bot session keys."
98 );
99 continue;
100 }
101 if platform_cfg.allow_from.is_empty() {
102 tracing::warn!(
103 "connect: telegram platform has an EMPTY allow_from list — every \
104 inbound message will be denied until you add allowed user ids to \
105 connect.platforms[].allow_from"
106 );
107 }
108
109 let platform: Arc<dyn Platform> =
110 Arc::new(platforms::telegram::TelegramPlatform::new(token));
111 spawn_platform_tasks(
112 &mut tasks,
113 &bridge,
114 platform,
115 platform_cfg.allow_from.clone(),
116 );
117 }
118 "feishu" => {
119 let app_id = platform_cfg.app_id.clone().unwrap_or_default();
120 let app_secret = platform_cfg.app_secret.clone().unwrap_or_default();
121 if app_id.trim().is_empty() || app_secret.trim().is_empty() {
122 tracing::warn!(
123 "connect: feishu platform configured without app_id/app_secret; \
124 skipping"
125 );
126 continue;
127 }
128 if !start_ok[index] {
133 tracing::warn!(
134 "connect: multiple feishu platform entries are configured; only the \
135 FIRST is started. A second feishu app on this instance would \
136 collide with the first on the same session-routing key \
137 (`feishu:<chat_id>:<open_id>`) and on inbound message dedup. \
138 Remove the extra entry, or track issue #454 for per-bot session \
139 keys."
140 );
141 continue;
142 }
143 let Some(base_url) = resolve_feishu_base_url(platform_cfg.domain.as_deref())
144 else {
145 tracing::warn!(
146 domain = platform_cfg.domain.as_deref().unwrap_or_default(),
147 "connect: feishu platform has an invalid domain (expected \"feishu\", \
148 \"lark\", or an https:// base URL); skipping"
149 );
150 continue;
151 };
152 if platform_cfg.allow_from.is_empty() {
153 tracing::warn!(
154 "connect: feishu platform has an EMPTY allow_from list — every \
155 inbound message will be denied until you add allowed open_ids to \
156 connect.platforms[].allow_from"
157 );
158 }
159
160 let platform: Arc<dyn Platform> = Arc::new(
161 platforms::feishu::FeishuPlatform::new(app_id, app_secret, base_url),
162 );
163 spawn_platform_tasks(
164 &mut tasks,
165 &bridge,
166 platform,
167 platform_cfg.allow_from.clone(),
168 );
169 }
170 other => {
171 tracing::warn!("connect: unknown platform type '{other}'; skipping");
172 }
173 }
174 }
175
176 Self { tasks }
177 }
178}
179
180fn spawn_platform_tasks(
185 tasks: &mut Vec<tokio::task::JoinHandle<()>>,
186 bridge: &Arc<ConnectBridge>,
187 platform: Arc<dyn Platform>,
188 allow_from: Vec<String>,
189) {
190 let name = platform.name().to_string();
191 let (tx, rx) = mpsc::channel(64);
192
193 let platform_for_start = platform.clone();
194 let name_for_start = name.clone();
195 tasks.push(tokio::spawn(async move {
196 if let Err(error) = platform_for_start.start(tx).await {
197 tracing::warn!("connect: {name_for_start} platform loop exited: {error}");
198 }
199 }));
200
201 tasks.push(tokio::spawn(dispatch_loop(
202 bridge.clone(),
203 platform,
204 allow_from,
205 rx,
206 )));
207
208 tracing::info!("connect: started {name} platform");
209}
210
211fn resolve_feishu_base_url(domain: Option<&str>) -> Option<String> {
217 match domain.map(str::trim).filter(|d| !d.is_empty()) {
218 None | Some("feishu") => Some("https://open.feishu.cn".to_string()),
219 Some("lark") => Some("https://open.larksuite.com".to_string()),
220 Some(custom) if custom.starts_with("https://") => {
221 Some(custom.trim_end_matches('/').to_string())
222 }
223 Some(_) => None,
224 }
225}
226
227impl Drop for ConnectManager {
228 fn drop(&mut self) {
229 for task in &self.tasks {
230 task.abort();
231 }
232 }
233}
234
235fn multi_bot_guard(platforms: &[ConnectPlatformConfig]) -> Vec<bool> {
260 let mut seen_valid: std::collections::HashSet<&str> = std::collections::HashSet::new();
261 platforms
262 .iter()
263 .map(|platform_cfg| {
264 let non_empty =
265 |field: &Option<String>| field.as_deref().is_some_and(|v| !v.trim().is_empty());
266 let valid = match platform_cfg.platform_type.as_str() {
267 "telegram" => non_empty(&platform_cfg.token),
268 "feishu" => non_empty(&platform_cfg.app_id) && non_empty(&platform_cfg.app_secret),
269 _ => return true,
270 };
271 if !valid {
272 return true;
273 }
274 seen_valid.insert(platform_cfg.platform_type.as_str())
275 })
276 .collect()
277}
278
279async fn dispatch_loop(
287 bridge: Arc<ConnectBridge>,
288 platform: Arc<dyn Platform>,
289 allow_from: Vec<String>,
290 mut rx: mpsc::Receiver<Inbound>,
291) {
292 while let Some(event) = rx.recv().await {
293 match event {
294 Inbound::Message(msg) => {
295 ConnectBridge::handle_inbound(
296 bridge.clone(),
297 platform.clone(),
298 allow_from.clone(),
299 msg,
300 )
301 .await;
302 }
303 Inbound::Callback(callback) => {
304 ConnectBridge::handle_callback(
305 bridge.clone(),
306 platform.clone(),
307 allow_from.clone(),
308 callback,
309 )
310 .await;
311 }
312 }
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 fn platform(platform_type: &str, token: Option<&str>) -> ConnectPlatformConfig {
321 ConnectPlatformConfig {
322 platform_type: platform_type.to_string(),
323 token: token.map(str::to_string),
324 token_encrypted: None,
325 app_id: None,
326 app_secret: None,
327 app_secret_encrypted: None,
328 domain: None,
329 allow_from: Vec::new(),
330 admin_from: Vec::new(),
331 }
332 }
333
334 fn feishu_platform(app_id: Option<&str>, app_secret: Option<&str>) -> ConnectPlatformConfig {
335 ConnectPlatformConfig {
336 app_id: app_id.map(str::to_string),
337 app_secret: app_secret.map(str::to_string),
338 ..platform("feishu", None)
339 }
340 }
341
342 #[test]
343 fn multi_bot_guard_allows_a_single_telegram_entry() {
344 let platforms = vec![platform("telegram", Some("tok-1"))];
345 assert_eq!(multi_bot_guard(&platforms), vec![true]);
346 }
347
348 #[test]
349 fn multi_bot_guard_rejects_every_telegram_entry_after_the_first() {
350 let platforms = vec![
351 platform("telegram", Some("tok-1")),
352 platform("telegram", Some("tok-2")),
353 platform("telegram", Some("tok-3")),
354 ];
355 assert_eq!(multi_bot_guard(&platforms), vec![true, false, false]);
356 }
357
358 #[test]
361 fn multi_bot_guard_is_scoped_per_platform_type() {
362 let platforms = vec![
363 platform("telegram", Some("tok-1")),
364 feishu_platform(Some("cli_a"), Some("secret-a")),
365 platform("telegram", Some("tok-2")),
366 feishu_platform(Some("cli_b"), Some("secret-b")),
367 ];
368 assert_eq!(multi_bot_guard(&platforms), vec![true, true, false, false]);
369 }
370
371 #[test]
376 fn multi_bot_guard_does_not_count_a_credentialless_entry_against_the_budget() {
377 let platforms = vec![
378 platform("telegram", None),
379 platform("telegram", Some("")),
380 platform("telegram", Some("tok-real")),
381 feishu_platform(Some("cli_a"), None),
382 feishu_platform(Some("cli_b"), Some("secret-real")),
383 ];
384 assert_eq!(
385 multi_bot_guard(&platforms),
386 vec![true, true, true, true, true]
387 );
388 }
389
390 #[test]
391 fn multi_bot_guard_leaves_unknown_platform_types_alone() {
392 let platforms = vec![
393 platform("dingtalk", Some("x")),
394 platform("dingtalk", Some("y")),
395 ];
396 assert_eq!(multi_bot_guard(&platforms), vec![true, true]);
397 }
398
399 #[test]
400 fn multi_bot_guard_handles_an_empty_platform_list() {
401 assert_eq!(multi_bot_guard(&[]), Vec::<bool>::new());
402 }
403
404 #[test]
405 fn resolve_feishu_base_url_covers_the_three_domain_forms() {
406 assert_eq!(
407 resolve_feishu_base_url(None).as_deref(),
408 Some("https://open.feishu.cn")
409 );
410 assert_eq!(
411 resolve_feishu_base_url(Some("feishu")).as_deref(),
412 Some("https://open.feishu.cn")
413 );
414 assert_eq!(
415 resolve_feishu_base_url(Some("")).as_deref(),
416 Some("https://open.feishu.cn")
417 );
418 assert_eq!(
419 resolve_feishu_base_url(Some("lark")).as_deref(),
420 Some("https://open.larksuite.com")
421 );
422 assert_eq!(
423 resolve_feishu_base_url(Some("https://feishu.example.corp/")).as_deref(),
424 Some("https://feishu.example.corp")
425 );
426 assert_eq!(
427 resolve_feishu_base_url(Some("http://insecure.example")),
428 None
429 );
430 assert_eq!(resolve_feishu_base_url(Some("dingtalk")), None);
431 }
432}