Skip to main content

agentmux_srv/messaging/telegram/
poller.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Telegram long-polling loop: `getUpdates` → dispatch → offset advance;
5//! outbound mpsc → REST send. Plays the same role as
6//! `discord::gateway::run_gateway_loop`, but with no session/resume state —
7//! long polling has no connection to lose beyond a single HTTP request, so
8//! this is a plain retry-forever loop around one HTTP GET.
9
10use std::collections::HashMap;
11use std::sync::{Arc, Mutex};
12use std::time::{Duration, Instant};
13
14use tokio::sync::mpsc;
15
16use crate::backend::reactive::handler::get_global_handler;
17use crate::backend::reactive::types::InjectionRequest;
18use crate::messaging::{BridgeHealth, BridgeStatus, OutboundMsg};
19
20use super::rest;
21use super::types::Update;
22use super::TelegramConfig;
23
24const RECONNECT_DELAY_SECS: u64 = 5;
25const MAX_RECONNECT_DELAY_SECS: u64 = 60;
26
27/// Minimum spacing between two outbound sends to the same chat (spec §2.5 —
28/// "1 msg/second" limit, enforced with a bit of headroom).
29const CHAT_MIN_SPACING: Duration = Duration::from_millis(1100);
30
31/// Runs the inbound long-poll loop and the outbound send loop as two fully
32/// independent tasks (`tokio::join!`, not `tokio::select!` on a shared loop
33/// iteration).
34///
35/// The original design interleaved both in one `tokio::select!`: whichever
36/// branch was constructed fresh each iteration. That meant an outbound send
37/// arriving mid-poll would drop (cancel) the in-flight `getUpdates` future,
38/// and — worse — any rate-limit backoff sleep inside outbound handling (up to
39/// a Telegram-specified `retry_after`, potentially many seconds) blocked the
40/// *entire* loop, including inbound polling, since both branches shared one
41/// iteration. A burst of outbound sends (e.g. `edit_message_id` streaming
42/// updates) could starve inbound message delivery for as long as the burst +
43/// backoff lasted. Splitting into two independently-scheduled tasks removes
44/// this coupling entirely: the inbound poll always runs on its own cadence
45/// regardless of outbound activity.
46pub async fn run_poll_loop(
47    config: TelegramConfig,
48    http: reqwest::Client,
49    outbound_rx: mpsc::UnboundedReceiver<OutboundMsg>,
50    health: Arc<Mutex<BridgeHealth>>,
51) {
52    let inbound_config = config.clone();
53    tokio::join!(
54        run_inbound_loop(inbound_config, health),
55        run_outbound_loop(config, http, outbound_rx),
56    );
57}
58
59async fn run_inbound_loop(config: TelegramConfig, health: Arc<Mutex<BridgeHealth>>) {
60    // Dedicated client for getUpdates: needs a longer per-request timeout than
61    // the 30s `timeout` param Telegram itself waits on (spec §9). Sends use
62    // a client with a short per-request timeout override instead (rest.rs),
63    // so a stuck send can't wedge this loop — and now, since sends run on a
64    // fully separate task, they couldn't wedge it even without that timeout.
65    let poll_http = rest::build_poll_client();
66
67    let mut offset: i64 = 0;
68    let mut delay_secs = RECONNECT_DELAY_SECS;
69
70    loop {
71        match rest::get_updates(&poll_http, &config.token, offset).await {
72            Ok(updates) => {
73                delay_secs = RECONNECT_DELAY_SECS;
74                {
75                    let mut h = health.lock().unwrap();
76                    h.status = BridgeStatus::Connected;
77                    h.error = None;
78                }
79
80                // Offset advances past every update in the batch — including
81                // ones that fail to parse — so a permanently-malformed update
82                // can never wedge the poll loop (spec §9).
83                let mut max_update_id = offset - 1;
84                for raw in &updates {
85                    let update_id = raw.get("update_id").and_then(|v| v.as_i64());
86                    let Some(update_id) = update_id else {
87                        tracing::warn!(
88                            "telegram_bridge: update missing update_id, skipping (offset cannot advance past it)"
89                        );
90                        continue;
91                    };
92                    if update_id > max_update_id {
93                        max_update_id = update_id;
94                    }
95
96                    match serde_json::from_value::<Update>(raw.clone()) {
97                        Ok(update) => handle_update(&update, &config, &health),
98                        Err(e) => {
99                            tracing::warn!(
100                                "telegram_bridge: malformed update {update_id}: {e} — skipping"
101                            );
102                        }
103                    }
104                }
105                if max_update_id >= offset {
106                    offset = max_update_id + 1;
107                }
108            }
109            Err(e) => {
110                tracing::warn!("telegram_bridge: getUpdates failed: {e}");
111                {
112                    let mut h = health.lock().unwrap();
113                    h.status = BridgeStatus::Error;
114                    h.error = Some(e);
115                    h.reconnect_count += 1;
116                }
117                tokio::time::sleep(Duration::from_secs(delay_secs)).await;
118                delay_secs = (delay_secs * 2).min(MAX_RECONNECT_DELAY_SECS);
119            }
120        }
121    }
122}
123
124async fn run_outbound_loop(
125    config: TelegramConfig,
126    http: reqwest::Client,
127    mut outbound_rx: mpsc::UnboundedReceiver<OutboundMsg>,
128) {
129    // Per-chat outbound rate limiting (spec §2.5, v1 scope: spacing + reactive
130    // 429 backoff only, no full token-bucket accounting). Owned entirely by
131    // this task now — never shared with the inbound loop.
132    let mut chat_last_sent: HashMap<i64, Instant> = HashMap::new();
133    let mut chat_backoff_until: HashMap<i64, Instant> = HashMap::new();
134    let mut global_backoff_until: Option<Instant> = None;
135
136    while let Some(msg) = outbound_rx.recv().await {
137        handle_outbound(
138            &http,
139            &config,
140            msg,
141            &mut chat_last_sent,
142            &mut chat_backoff_until,
143            &mut global_backoff_until,
144        )
145        .await;
146    }
147}
148
149/// Filters, envelopes, and injects one inbound update. Plays the same role as
150/// `gateway.rs`'s `handle_dispatch` for `MESSAGE_CREATE`.
151fn handle_update(update: &Update, config: &TelegramConfig, health: &Arc<Mutex<BridgeHealth>>) {
152    let Some(message) = &update.message else {
153        // callback_query (or another update kind we don't yet request) —
154        // inline-keyboard handling is PR4 scope, not implemented here.
155        tracing::debug!("telegram_bridge: unhandled update kind (update_id={})", update.update_id);
156        return;
157    };
158
159    // Ignore messages from bots (including our own bot, e.g. echoes).
160    if message.from.as_ref().map(|u| u.is_bot).unwrap_or(false) {
161        return;
162    }
163
164    // Allowlist is the *only* gate for Telegram (spec §8.1) — a bot's
165    // username is public, unlike Discord's invite-gated guild membership.
166    // Silently drop, never reply, so an unlisted chat can't confirm the bot
167    // is alive.
168    if !config.allowed_chat_ids.contains(&message.chat.id) {
169        tracing::debug!(
170            "telegram_bridge: dropping message from non-allowlisted chat {}",
171            message.chat.id
172        );
173        return;
174    }
175
176    {
177        let mut h = health.lock().unwrap();
178        h.last_event_at = Some(unix_secs());
179    }
180
181    let Some(target) = &config.target_agent else {
182        tracing::debug!("telegram_bridge: message from chat {} (no target agent configured)", message.chat.id);
183        return;
184    };
185
186    let username = message
187        .from
188        .as_ref()
189        .and_then(|u| u.username.as_deref())
190        .unwrap_or("unknown");
191    let text = message.text.as_deref().unwrap_or("");
192    let envelope = format!("[Telegram @{username}]: {text}");
193
194    let handler = get_global_handler();
195    let req = InjectionRequest {
196        target_agent: target.clone(),
197        message: envelope,
198        source_agent: Some("telegram".to_string()),
199        request_id: Some(update.update_id.to_string()),
200        priority: None,
201        jekt_tier: None,
202        delivery_tier: Some("wan".to_string()),
203        forward_hops: 0,
204        ..Default::default()
205    };
206    let result = handler.inject_message(req);
207    if result.success {
208        tracing::debug!("telegram_bridge: injected msg from {username} to agent {target}");
209    } else {
210        tracing::warn!("telegram_bridge: inject to agent {target} failed: {:?}", result.error);
211    }
212}
213
214/// Resolves an `OutboundMsg.channel_id` (a stringified chat id, or empty for
215/// "use the bridge's default") into a Telegram numeric chat id.
216fn resolve_chat_id(channel_id: &str, default_chat_id: Option<i64>) -> Option<i64> {
217    if channel_id.is_empty() {
218        return default_chat_id;
219    }
220    channel_id.trim().parse::<i64>().ok().or(default_chat_id)
221}
222
223async fn handle_outbound(
224    http: &reqwest::Client,
225    config: &TelegramConfig,
226    msg: OutboundMsg,
227    chat_last_sent: &mut HashMap<i64, Instant>,
228    chat_backoff_until: &mut HashMap<i64, Instant>,
229    global_backoff_until: &mut Option<Instant>,
230) {
231    let Some(chat_id) = resolve_chat_id(&msg.channel_id, config.default_chat_id) else {
232        tracing::warn!(
233            "telegram_bridge: send dropped — no chat_id (empty channel_id and no messaging:telegram:default_chat configured)"
234        );
235        return;
236    };
237
238    if let Some(until) = *global_backoff_until {
239        let now = Instant::now();
240        if now < until {
241            tokio::time::sleep(until - now).await;
242        }
243        *global_backoff_until = None;
244    }
245
246    if let Some(until) = chat_backoff_until.get(&chat_id).copied() {
247        let now = Instant::now();
248        if now < until {
249            tokio::time::sleep(until - now).await;
250        }
251        chat_backoff_until.remove(&chat_id);
252    }
253
254    if let Some(last) = chat_last_sent.get(&chat_id) {
255        let elapsed = last.elapsed();
256        if elapsed < CHAT_MIN_SPACING {
257            tokio::time::sleep(CHAT_MIN_SPACING - elapsed).await;
258        }
259    }
260
261    match rest::send_or_edit(http, &config.token, chat_id, &msg.text, msg.edit_message_id).await {
262        Ok(_) => {
263            chat_last_sent.insert(chat_id, Instant::now());
264        }
265        Err(e) => {
266            tracing::warn!("telegram_bridge: send failed: {e}");
267            if let Some(retry_after) = e.retry_after {
268                let backoff = Duration::from_secs(retry_after) + jitter();
269                if e.chat_scoped {
270                    chat_backoff_until.insert(chat_id, Instant::now() + backoff);
271                } else {
272                    *global_backoff_until = Some(Instant::now() + backoff);
273                }
274            }
275        }
276    }
277}
278
279/// Cheap jitter (0-300ms) without pulling in a `rand` dependency — derived
280/// from the current time's sub-second nanoseconds.
281fn jitter() -> Duration {
282    let nanos = std::time::SystemTime::now()
283        .duration_since(std::time::UNIX_EPOCH)
284        .map(|d| d.subsec_nanos())
285        .unwrap_or(0);
286    Duration::from_millis((nanos % 300) as u64)
287}
288
289fn unix_secs() -> u64 {
290    agentmux_common::time::now_secs_u64()
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn resolve_chat_id_uses_channel_id_when_present() {
299        assert_eq!(resolve_chat_id("123456", Some(999)), Some(123456));
300    }
301
302    #[test]
303    fn resolve_chat_id_falls_back_to_default_when_empty() {
304        assert_eq!(resolve_chat_id("", Some(999)), Some(999));
305    }
306
307    #[test]
308    fn resolve_chat_id_none_when_empty_and_no_default() {
309        assert_eq!(resolve_chat_id("", None), None);
310    }
311
312    #[test]
313    fn resolve_chat_id_falls_back_to_default_on_unparseable_channel_id() {
314        assert_eq!(resolve_chat_id("not-a-number", Some(999)), Some(999));
315    }
316
317    #[test]
318    fn resolve_chat_id_trims_whitespace() {
319        assert_eq!(resolve_chat_id("  42  ", None), Some(42));
320    }
321}