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//! ```
17//!
18//! [`ConnectManager`] is constructed once at server startup (mirrors
19//! `ScheduleManager` / the notification relay — see
20//! `app_state::init::build_connect_manager`) and is FULLY INERT when
21//! `config.connect.platforms` is empty: no `[connect]` section in
22//! `config.json` means zero background tasks are spawned.
23
24pub mod bridge;
25pub mod platform;
26pub mod platforms;
27pub mod render;
28
29pub use bridge::{ConnectBridge, ConnectContext, SessionKey};
30pub use platform::{
31    Capabilities, InboundMessage, MessageRef, OutboundMessage, Platform, PlatformError,
32    PlatformResult, ReplyCtx,
33};
34
35use std::path::PathBuf;
36use std::sync::Arc;
37
38use tokio::sync::mpsc;
39
40use bamboo_llm::Config;
41
42/// Owns every configured platform's long-poll/dispatch background task plus
43/// the shared [`ConnectBridge`]. All tasks are aborted when the manager
44/// drops (mirrors `app_state::builder::EmbeddedBroker`/`HealthMonitor`'s
45/// Drop-based stop — the closest server-lifecycle precedent for a
46/// fire-and-forget background subsystem).
47pub struct ConnectManager {
48    tasks: Vec<tokio::task::JoinHandle<()>>,
49}
50
51impl ConnectManager {
52    /// Builds the bridge, loads its persisted session map, and starts one
53    /// long-poll + one dispatch task per recognized platform entry in
54    /// `config_snapshot.connect.platforms`. An empty (or absent) `[connect]`
55    /// config starts zero tasks — fully inert by default, per #452.
56    pub async fn start(
57        ctx: ConnectContext,
58        config_snapshot: &Config,
59        data_dir: Option<PathBuf>,
60    ) -> Self {
61        let map_path = data_dir.map(|dir| dir.join("connect_sessions.json"));
62        let bridge = Arc::new(ConnectBridge::new(ctx, map_path));
63        bridge.load_session_map().await;
64
65        let mut tasks = Vec::new();
66        for platform_cfg in &config_snapshot.connect.platforms {
67            match platform_cfg.platform_type.as_str() {
68                "telegram" => {
69                    let token = platform_cfg.token.clone().unwrap_or_default();
70                    if token.trim().is_empty() {
71                        tracing::warn!(
72                            "connect: telegram platform configured without a token; skipping"
73                        );
74                        continue;
75                    }
76                    if platform_cfg.allow_from.is_empty() {
77                        tracing::warn!(
78                            "connect: telegram platform has an EMPTY allow_from list — every \
79                             inbound message will be denied until you add allowed user ids to \
80                             connect.platforms[].allow_from"
81                        );
82                    }
83
84                    let platform: Arc<dyn Platform> =
85                        Arc::new(platforms::telegram::TelegramPlatform::new(token));
86                    let allow_from = platform_cfg.allow_from.clone();
87                    let (tx, rx) = mpsc::channel(64);
88
89                    let platform_for_start = platform.clone();
90                    tasks.push(tokio::spawn(async move {
91                        if let Err(error) = platform_for_start.start(tx).await {
92                            tracing::warn!("connect: telegram platform loop exited: {error}");
93                        }
94                    }));
95
96                    let bridge_for_dispatch = bridge.clone();
97                    let platform_for_dispatch = platform.clone();
98                    tasks.push(tokio::spawn(dispatch_loop(
99                        bridge_for_dispatch,
100                        platform_for_dispatch,
101                        allow_from,
102                        rx,
103                    )));
104
105                    tracing::info!("connect: started telegram platform");
106                }
107                other => {
108                    tracing::warn!("connect: unknown platform type '{other}'; skipping");
109                }
110            }
111        }
112
113        Self { tasks }
114    }
115}
116
117impl Drop for ConnectManager {
118    fn drop(&mut self) {
119        for task in &self.tasks {
120            task.abort();
121        }
122    }
123}
124
125/// Pulls inbound messages off a single platform's channel and hands each to
126/// the bridge. Kept as its OWN task per platform (not merged into the
127/// platform's `start()` loop) so a slow/queued chat can never stall the next
128/// `getUpdates` poll — `ConnectBridge::handle_inbound` itself returns quickly
129/// (it spawns the actual run), so this loop only ever blocks briefly.
130async fn dispatch_loop(
131    bridge: Arc<ConnectBridge>,
132    platform: Arc<dyn Platform>,
133    allow_from: Vec<String>,
134    mut rx: mpsc::Receiver<InboundMessage>,
135) {
136    while let Some(msg) = rx.recv().await {
137        ConnectBridge::handle_inbound(bridge.clone(), platform.clone(), allow_from.clone(), msg)
138            .await;
139    }
140}