Skip to main content

bamboo_server/connect/
mod.rs

1//! bamboo-connect: drive bamboo sessions from IM platforms (Telegram first).
2//!
3//! Issue #452 (MVP, phase 1 of epic #447). Sibling module of
4//! `schedule_app`/`notify_sinks` — the closest precedents: `notify_sinks` is
5//! one-way outbound, `schedule_app::manager` is the canonical
6//! background-execution pattern this module's [`bridge::ConnectBridge`]
7//! reuses verbatim (`spawn_session_execution`, event-forwarder, runner
8//! reservation).
9//!
10//! ```text
11//! connect/
12//!   platform.rs      — the Platform trait + Capabilities + message types
13//!   bridge.rs         — chat ⇄ bamboo-session routing, busy lock, queueing
14//!   render.rs         — AgentEvent stream → platform messages
15//!   platforms/telegram.rs — long-poll adapter
16//!   platforms/feishu/     — Feishu/Lark WS long-connection adapter (phase 3)
17//! ```
18//!
19//! [`ConnectManager`] is constructed once at server startup (mirrors
20//! `ScheduleManager` / the notification relay — see
21//! `app_state::init::build_connect_manager`) and is FULLY INERT when
22//! `config.connect.platforms` is empty: no `[connect]` section in
23//! `config.json` means zero background tasks are spawned.
24
25pub 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
45/// Owns every configured platform's long-poll/dispatch background task plus
46/// the shared [`ConnectBridge`]. All tasks are aborted when the manager
47/// drops (mirrors `app_state::builder::EmbeddedBroker`/`HealthMonitor`'s
48/// Drop-based stop — the closest server-lifecycle precedent for a
49/// fire-and-forget background subsystem).
50pub struct ConnectManager {
51    tasks: Vec<tokio::task::JoinHandle<()>>,
52}
53
54impl ConnectManager {
55    /// Builds the bridge, loads its persisted session map, and starts one
56    /// long-poll + one dispatch task per recognized platform entry in
57    /// `config_snapshot.connect.platforms`. An empty (or absent) `[connect]`
58    /// config starts zero tasks — fully inert by default, per #452.
59    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                    // Issue #454 follow-up: `SessionKey`/`InboundMessage.platform`
81                    // hardcode `"telegram"` for every adapter instance, so two
82                    // live telegram bots on one server would collide on
83                    // `telegram:<chat_id>:<user_id>` — a private chat's
84                    // `chat_id` equals the Telegram user id, so a user who
85                    // messages BOTH bots would silently share one bamboo
86                    // session across them. Until per-bot session keys are
87                    // supported, start at most the first validly-configured
88                    // telegram entry and reject the rest with a clear warning
89                    // rather than let them collide.
90                    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                    // Same session-key collision as telegram's guard above:
129                    // `SessionKey` hardcodes "feishu", and two apps in one
130                    // group chat would also dedup-eat each other's copy of
131                    // the same message_id. At most one live feishu entry.
132                    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
180/// Spawns the pair of background tasks every platform entry needs — the
181/// adapter's own `start()` loop and its [`dispatch_loop`] — and logs the
182/// startup. Factored out of the per-platform match arms, which only differ in
183/// how they validate config and construct the adapter.
184fn 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
211/// Resolves the `domain` config field of a feishu platform entry to an API
212/// base URL: absent/`"feishu"` → open.feishu.cn, `"lark"` → open.larksuite.com
213/// (Lark international), any `https://` value → private-deployment base used
214/// as-is (trailing slash trimmed). Anything else is invalid — the caller
215/// warns and skips the entry.
216fn 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
235/// Issue #454 follow-up (multi-bot session-key collision): for each entry in
236/// `platforms` (same order/length as the input), returns whether
237/// [`ConnectManager::start`] is allowed to start it as far as the "at most
238/// one live bot PER PLATFORM TYPE" guard is concerned.
239///
240/// `SessionKey`/`InboundMessage.platform` hardcode the platform name — there
241/// is no per-bot/config-index component in the session-routing key — so two
242/// entries of the same type running at once would route messages from the
243/// SAME user to different bots into the SAME bamboo session key, mixing
244/// their conversations (and, since the inbound dedup key is
245/// `platform:message_id`, two feishu apps in one group chat would even eat
246/// each other's copy of the same message). Rather than the more invasive fix
247/// of threading a bot identity through `SessionKey` (touching the routing
248/// key, the persisted session map's key format, and every call site that
249/// builds one), this rejects the collision at the source: only the FIRST
250/// validly-configured entry of each platform type is ever started; every
251/// later same-type entry is guarded off regardless of how many are
252/// configured.
253///
254/// Entries with empty/absent credentials are left `true` — they're handled
255/// by [`ConnectManager::start`]'s pre-existing "not configured" skip, which
256/// doesn't count as a "started" bot for this guard's purposes (so a blank
257/// placeholder entry followed by one real entry still starts the real one).
258/// Unknown platform types are always `true` — `start` skips them anyway.
259fn 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
279/// Pulls inbound events (messages and button-press callbacks, issue #458)
280/// off a single platform's channel and hands each to the bridge. Kept as its
281/// OWN task per platform (not merged into the platform's `start()` loop) so
282/// a slow/queued chat can never stall the next `getUpdates` poll —
283/// `ConnectBridge::handle_inbound`/`handle_callback` themselves return
284/// quickly (a message spawns the actual run; a callback only ever
285/// acks+resolves), so this loop only ever blocks briefly.
286async 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    /// The guard is PER platform type: one telegram and one feishu entry can
359    /// coexist, but a second live entry of the SAME type is rejected.
360    #[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    /// A blank/absent-credential entry doesn't count as a "started" bot for
372    /// this guard — `ConnectManager::start`'s pre-existing empty-credential
373    /// check skips it separately, so the NEXT (real) entry of the same type
374    /// must still be allowed to start.
375    #[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}