agentmux_srv/messaging/telegram/
poller.rs1use 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
27const CHAT_MIN_SPACING: Duration = Duration::from_millis(1100);
30
31pub 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 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 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 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
149fn handle_update(update: &Update, config: &TelegramConfig, health: &Arc<Mutex<BridgeHealth>>) {
152 let Some(message) = &update.message else {
153 tracing::debug!("telegram_bridge: unhandled update kind (update_id={})", update.update_id);
156 return;
157 };
158
159 if message.from.as_ref().map(|u| u.is_bot).unwrap_or(false) {
161 return;
162 }
163
164 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
214fn 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
279fn 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}