Skip to main content

orch/channels/
telegram.rs

1//! Telegram channel — receives commands and streams agent output.
2//!
3//! Uses the Telegram Bot API to receive commands and stream agent output.
4
5use super::{Channel, IncomingMessage, OutgoingMessage};
6use anyhow::Context;
7use async_trait::async_trait;
8use chrono::{DateTime, Utc};
9use reqwest::Client;
10use serde::Deserialize;
11use sha2::{Digest, Sha256};
12use std::time::Duration;
13
14const TELEGRAM_LONG_POLL_TIMEOUT_SECS: u64 = 30;
15const TELEGRAM_HTTP_TIMEOUT_SECS: u64 = TELEGRAM_LONG_POLL_TIMEOUT_SECS + 15;
16const TELEGRAM_MAX_RETRIES: u32 = 3;
17const _: () = assert!(TELEGRAM_HTTP_TIMEOUT_SECS > TELEGRAM_LONG_POLL_TIMEOUT_SECS);
18
19/// Escape text for Telegram HTML parse_mode.
20///
21/// Telegram's HTML parser is strict: bare `<`, `>`, or `&` cause
22/// "can't parse entities" errors. This function escapes those characters.
23// Telegram refuses a message over 4096 characters, so parts stay below it with room to spare
24const MAX_MESSAGE_CHARS: usize = 4000;
25
26/* Split plain text into parts whose escaped form fits one message. It breaks at line ends, and
27hard-splits only a line too long by itself. Splitting before escaping means no part can cut an
28entity such as `&amp;` in half */
29fn split_for_telegram(text: &str, max: usize) -> Vec<String> {
30    let size = |s: &str| html_escape(s).chars().count();
31    let mut parts = Vec::new();
32    let mut current = String::new();
33    for line in text.split_inclusive('\n') {
34        if !current.is_empty() && size(&current) + size(line) > max {
35            parts.push(std::mem::take(&mut current));
36        }
37        if size(line) <= max {
38            current.push_str(line);
39            continue;
40        }
41
42        for c in line.chars() {
43            let mut buf = [0u8; 4];
44            let c = c.encode_utf8(&mut buf);
45            if size(&current) + size(c) > max {
46                parts.push(std::mem::take(&mut current));
47            }
48            current.push_str(c);
49        }
50    }
51    if !current.is_empty() || parts.is_empty() {
52        parts.push(current);
53    }
54    parts
55}
56
57fn html_escape(s: &str) -> String {
58    s.replace('&', "&amp;")
59        .replace('<', "&lt;")
60        .replace('>', "&gt;")
61}
62
63fn is_preformatted_html(msg: &OutgoingMessage) -> bool {
64    msg.metadata
65        .get("preformatted_html")
66        .and_then(|v| v.as_bool())
67        .unwrap_or(false)
68}
69
70pub struct TelegramChannel {
71    pub token: String,
72    pub client: Client,
73    pub chat_id: Option<String>,
74    pub offset: std::sync::Arc<tokio::sync::Mutex<i64>>,
75}
76
77#[derive(Deserialize)]
78struct TelegramUser {
79    first_name: String,
80    #[serde(default)]
81    username: Option<String>,
82}
83
84#[derive(Deserialize)]
85struct TelegramMessage {
86    message_id: i64,
87    from: Option<TelegramUser>,
88    chat: TelegramChat,
89    text: Option<String>,
90    date: i64,
91    #[serde(default)]
92    message_thread_id: Option<i64>,
93}
94
95#[derive(Deserialize)]
96struct TelegramChat {
97    id: i64,
98}
99
100#[derive(Deserialize)]
101struct CallbackQuery {
102    id: String,
103    from: TelegramUser,
104    message: Option<TelegramMessage>,
105    data: Option<String>,
106}
107
108#[derive(Deserialize)]
109struct Update {
110    update_id: i64,
111    message: Option<TelegramMessage>,
112    callback_query: Option<CallbackQuery>,
113}
114
115#[derive(Deserialize)]
116struct GetUpdatesResponse {
117    ok: bool,
118    result: Vec<Update>,
119}
120
121impl TelegramChannel {
122    pub fn new(token: String, chat_id: Option<String>) -> anyhow::Result<Self> {
123        let client = Client::builder()
124            .timeout(Duration::from_secs(TELEGRAM_HTTP_TIMEOUT_SECS))
125            .build()
126            .context("failed to build HTTP client")?;
127
128        Ok(Self {
129            token,
130            client,
131            chat_id,
132            offset: std::sync::Arc::new(tokio::sync::Mutex::new(0)),
133        })
134    }
135
136    fn api_url(&self, method: &str) -> String {
137        format!("https://api.telegram.org/bot{}/{}", self.token, method)
138    }
139
140    async fn get_updates(&self, offset: i64) -> anyhow::Result<Vec<Update>> {
141        let url = self.api_url("getUpdates");
142
143        let params = serde_json::json!({
144            "offset": offset,
145            "timeout": TELEGRAM_LONG_POLL_TIMEOUT_SECS,
146            "allowed_updates": ["message", "callback_query"]
147        });
148
149        for attempt in 0..TELEGRAM_MAX_RETRIES {
150            let response = match self.client.post(&url).json(&params).send().await {
151                Ok(r) => r,
152                Err(e) => {
153                    if attempt + 1 < TELEGRAM_MAX_RETRIES {
154                        tokio::time::sleep(Duration::from_secs(2)).await;
155                        continue;
156                    }
157                    anyhow::bail!(
158                        "telegram getUpdates failed after {} attempts: {e}",
159                        TELEGRAM_MAX_RETRIES,
160                    );
161                }
162            };
163
164            if !response.status().is_success() {
165                let body = response.text().await.unwrap_or_default();
166                anyhow::bail!("telegram API error: {}", body);
167            }
168
169            let updates: GetUpdatesResponse = response.json().await?;
170
171            if !updates.ok {
172                anyhow::bail!("telegram API returned ok=false");
173            }
174
175            return Ok(updates.result);
176        }
177
178        anyhow::bail!(
179            "telegram getUpdates failed after {} attempts",
180            TELEGRAM_MAX_RETRIES,
181        );
182    }
183
184    async fn send_message(
185        &self,
186        chat_id: i64,
187        text: &str,
188        topic_id: Option<i64>,
189    ) -> anyhow::Result<()> {
190        for part in split_for_telegram(text, MAX_MESSAGE_CHARS) {
191            self.send_formatted_message(chat_id, &html_escape(&part), topic_id)
192                .await?;
193        }
194        Ok(())
195    }
196
197    async fn send_formatted_message(
198        &self,
199        chat_id: i64,
200        text: &str,
201        topic_id: Option<i64>,
202    ) -> anyhow::Result<()> {
203        let url = self.api_url("sendMessage");
204
205        // Use HTML parse mode. Callers must ensure text is properly escaped
206        // (send_message escapes automatically; send_formatted_message expects
207        // pre-escaped/pre-formatted input from TaskNotification::format_telegram).
208        let mut params = serde_json::json!({
209            "chat_id": chat_id,
210            "text": text,
211            "parse_mode": "HTML"
212        });
213        if let Some(tid) = topic_id {
214            params["message_thread_id"] = serde_json::json!(tid);
215        }
216
217        let response = self.client.post(&url).json(&params).send().await?;
218
219        if !response.status().is_success() {
220            let body = response.text().await.unwrap_or_default();
221            anyhow::bail!("telegram API error: {}", body);
222        }
223
224        Ok(())
225    }
226
227    #[allow(dead_code)]
228    pub async fn send_inline_keyboard(
229        &self,
230        chat_id: i64,
231        topic_id: Option<i64>,
232        text: &str,
233        buttons: &[(String, String)], // (label, callback_data)
234    ) -> anyhow::Result<i64> {
235        let keyboard: Vec<Vec<serde_json::Value>> = buttons
236            .iter()
237            .map(|(label, data)| {
238                vec![serde_json::json!({
239                    "text": label,
240                    "callback_data": data
241                })]
242            })
243            .collect();
244
245        let mut params = serde_json::json!({
246            "chat_id": chat_id,
247            "text": text,
248            "reply_markup": { "inline_keyboard": keyboard }
249        });
250        if let Some(tid) = topic_id {
251            params["message_thread_id"] = serde_json::json!(tid);
252        }
253
254        let url = self.api_url("sendMessage");
255        let response = self.client.post(&url).json(&params).send().await?;
256        if !response.status().is_success() {
257            let body = response.text().await.unwrap_or_default();
258            anyhow::bail!("telegram API error: {}", body);
259        }
260        let result: serde_json::Value = response.json().await?;
261        let message_id = result["result"]["message_id"].as_i64().unwrap_or(0);
262        Ok(message_id)
263    }
264
265    #[allow(dead_code)]
266    pub async fn answer_callback_query(
267        &self,
268        callback_query_id: &str,
269        text: &str,
270    ) -> anyhow::Result<()> {
271        let url = self.api_url("answerCallbackQuery");
272        let params = serde_json::json!({
273            "callback_query_id": callback_query_id,
274            "text": text
275        });
276        let response = self.client.post(&url).json(&params).send().await?;
277        if !response.status().is_success() {
278            let body = response.text().await.unwrap_or_default();
279            anyhow::bail!("telegram API error: {}", body);
280        }
281        Ok(())
282    }
283}
284
285#[async_trait]
286impl Channel for TelegramChannel {
287    fn name(&self) -> &str {
288        "telegram"
289    }
290
291    async fn start(&self) -> anyhow::Result<tokio::sync::mpsc::Receiver<IncomingMessage>> {
292        let (tx, rx) = tokio::sync::mpsc::channel(64);
293        let token = self.token.clone();
294        let client = self.client.clone();
295        let chat_id = self.chat_id.clone();
296        let offset = self.offset.clone();
297
298        let token_fingerprint = {
299            let mut hasher = Sha256::new();
300            hasher.update(token.as_bytes());
301            let hash = hasher.finalize();
302            let hex: String = hash[..8].iter().map(|b| format!("{:02x}", b)).collect();
303            format!("sha256:{hex}")
304        };
305
306        tracing::info!(token_fingerprint = %token_fingerprint, "telegram channel started");
307
308        // Create a single TelegramChannel instance to reuse for all API calls
309        let channel = TelegramChannel {
310            token: token.clone(),
311            client: client.clone(),
312            chat_id: chat_id.clone(),
313            offset: offset.clone(),
314        };
315
316        tokio::spawn(async move {
317            const POLL_BACKOFF_BASE: Duration = Duration::from_secs(5);
318            const POLL_BACKOFF_CAP: Duration = Duration::from_secs(60);
319            let mut backoff = POLL_BACKOFF_BASE;
320
321            loop {
322                let current_offset = {
323                    let off = offset.lock().await;
324                    *off
325                };
326
327                let updates = match channel.get_updates(current_offset).await {
328                    Ok(u) => u,
329                    Err(e) => {
330                        tracing::warn!(
331                            ?e,
332                            backoff_secs = backoff.as_secs(),
333                            "failed to get telegram updates"
334                        );
335                        tokio::time::sleep(backoff).await;
336                        backoff = (backoff * 2).min(POLL_BACKOFF_CAP);
337                        continue;
338                    }
339                };
340                backoff = POLL_BACKOFF_BASE;
341
342                let has_updates = !updates.is_empty();
343
344                for update in updates {
345                    // Update offset
346                    {
347                        let mut off = offset.lock().await;
348                        if update.update_id + 1 > *off {
349                            *off = update.update_id + 1;
350                        }
351                    }
352
353                    // Handle callback queries
354                    if let Some(cb) = update.callback_query {
355                        let chat_id_val = cb
356                            .message
357                            .as_ref()
358                            .map(|m| m.chat.id.to_string())
359                            .unwrap_or_default();
360                        let topic_id = cb
361                            .message
362                            .as_ref()
363                            .and_then(|m| m.message_thread_id)
364                            .map(|id| id.to_string());
365                        let author = cb
366                            .from
367                            .username
368                            .clone()
369                            .unwrap_or(cb.from.first_name.clone());
370                        let body = cb.data.clone().unwrap_or_default();
371                        let msg_id = cb
372                            .message
373                            .as_ref()
374                            .map(|m| m.message_id.to_string())
375                            .unwrap_or_default();
376                        let msg_date = cb.message.as_ref().map(|m| m.date).unwrap_or(0);
377
378                        let incoming = IncomingMessage {
379                            channel: "telegram".to_string(),
380                            id: msg_id,
381                            thread_id: chat_id_val,
382                            author,
383                            body,
384                            timestamp: DateTime::from_timestamp(msg_date, 0)
385                                .unwrap_or_else(Utc::now),
386                            metadata: serde_json::json!({
387                                "callback_query_id": cb.id,
388                                "callback_data": cb.data
389                            }),
390                            topic_id,
391                        };
392
393                        if tx.send(incoming).await.is_err() {
394                            tracing::debug!("telegram channel receiver dropped");
395                            return;
396                        }
397                        continue;
398                    }
399
400                    // Handle regular messages
401                    let msg = match update.message {
402                        Some(m) => m,
403                        None => continue,
404                    };
405
406                    let author = msg
407                        .from
408                        .as_ref()
409                        .map(|u| u.username.clone().unwrap_or(u.first_name.clone()))
410                        .unwrap_or_else(|| "unknown".to_string());
411
412                    let body = msg.text.unwrap_or_default();
413
414                    // Skip empty messages or non-command messages unless we have a specific chat_id
415                    if body.is_empty() {
416                        continue;
417                    }
418
419                    let topic_id = msg.message_thread_id.map(|id| id.to_string());
420
421                    let incoming = IncomingMessage {
422                        channel: "telegram".to_string(),
423                        id: msg.message_id.to_string(),
424                        thread_id: msg.chat.id.to_string(),
425                        author,
426                        body,
427                        timestamp: DateTime::from_timestamp(msg.date, 0).unwrap_or_else(Utc::now),
428                        metadata: serde_json::json!({ "chat_id": msg.chat.id }),
429                        topic_id,
430                    };
431
432                    if tx.send(incoming).await.is_err() {
433                        tracing::debug!("telegram channel receiver dropped");
434                        return;
435                    }
436                }
437
438                // If no updates, sleep briefly to avoid busy looping
439                if !has_updates {
440                    tokio::time::sleep(std::time::Duration::from_secs(1)).await;
441                }
442            }
443        });
444
445        Ok(rx)
446    }
447
448    async fn send(&self, msg: &OutgoingMessage) -> anyhow::Result<()> {
449        // Resolve chat_id: metadata override → configured default.
450        let chat_id = if let Some(override_val) = msg.metadata.get("chat_id_override") {
451            override_val
452                .as_str()
453                .ok_or_else(|| anyhow::anyhow!("chat_id_override is not a string"))?
454                .parse::<i64>()
455                .map_err(|_| anyhow::anyhow!("invalid chat_id_override"))?
456        } else {
457            self.chat_id
458                .as_ref()
459                .ok_or_else(|| anyhow::anyhow!("telegram chat_id not configured"))?
460                .parse::<i64>()
461                .map_err(|_| anyhow::anyhow!("invalid chat_id"))?
462        };
463
464        let topic_id = msg.topic_id.as_ref().and_then(|t| t.parse::<i64>().ok());
465
466        // If metadata carries inline keyboard buttons, send as interactive message.
467        if let Some(buttons_val) = msg.metadata.get("buttons") {
468            if let Some(buttons_arr) = buttons_val.as_array() {
469                let buttons: Vec<(String, String)> = buttons_arr
470                    .iter()
471                    .filter_map(|b| {
472                        let text = b["text"].as_str()?.to_string();
473                        let callback_data = b["callback_data"].as_str()?.to_string();
474                        Some((text, callback_data))
475                    })
476                    .collect();
477                if !buttons.is_empty() {
478                    self.send_inline_keyboard(chat_id, topic_id, &msg.body, &buttons)
479                        .await?;
480                    return Ok(());
481                }
482            }
483        }
484
485        // Check if body is already pre-formatted HTML (e.g. TaskNotification::format_telegram)
486        // or raw text that needs escaping (e.g. streamed agent output).
487        let preformatted = is_preformatted_html(msg);
488
489        if preformatted {
490            self.send_formatted_message(chat_id, &msg.body, topic_id)
491                .await
492        } else {
493            self.send_message(chat_id, &msg.body, topic_id).await
494        }
495    }
496
497    async fn ack_interaction(&self, callback_query_id: &str) -> anyhow::Result<()> {
498        self.answer_callback_query(callback_query_id, "").await
499    }
500
501    async fn health_check(&self) -> anyhow::Result<()> {
502        let url = self.api_url("getMe");
503
504        let response = self.client.get(&url).send().await?;
505
506        if !response.status().is_success() {
507            let body = response.text().await.unwrap_or_default();
508            anyhow::bail!("telegram health check failed: {}", body);
509        }
510
511        tracing::info!("telegram bot health check passed");
512        Ok(())
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    #[test]
521    fn a_short_message_is_one_part() {
522        assert_eq!(split_for_telegram("hello\nworld", 4000), ["hello\nworld"]);
523        assert_eq!(split_for_telegram("", 4000), [""]);
524    }
525
526    #[test]
527    fn a_long_message_splits_at_line_ends_and_loses_nothing() {
528        let text: String = (0..400)
529            .map(|i| format!("line {i} with some text\n"))
530            .collect();
531        let parts = split_for_telegram(&text, 4000);
532        assert!(parts.len() > 1);
533        assert_eq!(parts.concat(), text);
534        for part in &parts {
535            assert!(html_escape(part).chars().count() <= 4000);
536            assert!(part.ends_with('\n'), "a part ended mid line");
537        }
538    }
539
540    #[test]
541    fn escaping_counts_toward_the_limit_and_an_entity_is_never_cut() {
542        let text = "&".repeat(3000);
543        let parts = split_for_telegram(&text, 4000);
544        assert_eq!(parts.concat(), text);
545        for part in &parts {
546            let escaped = html_escape(part);
547            assert!(escaped.chars().count() <= 4000);
548            assert_eq!(escaped.matches("&amp;").count(), part.len());
549        }
550    }
551
552    #[test]
553    fn a_line_longer_than_the_limit_is_hard_split_on_char_boundaries() {
554        let text = "é".repeat(9000);
555        let parts = split_for_telegram(&text, 4000);
556        assert_eq!(parts.len(), 3);
557        assert_eq!(parts.concat(), text);
558    }
559    use serde_json::json;
560
561    #[test]
562    fn html_escape_escapes_telegram_html_entities() {
563        assert_eq!(
564            html_escape("a < b && c > d"),
565            "a &lt; b &amp;&amp; c &gt; d"
566        );
567    }
568
569    #[test]
570    fn preformatted_html_defaults_to_false() {
571        let msg = OutgoingMessage {
572            thread_id: "thread".to_string(),
573            body: "raw <text>".to_string(),
574            reply_to: None,
575            metadata: serde_json::Value::Null,
576            topic_id: None,
577        };
578
579        assert!(!is_preformatted_html(&msg));
580    }
581
582    #[test]
583    fn preformatted_html_honors_metadata_flag() {
584        let msg = OutgoingMessage {
585            thread_id: "thread".to_string(),
586            body: "<b>formatted</b>".to_string(),
587            reply_to: None,
588            metadata: json!({ "preformatted_html": true }),
589            topic_id: None,
590        };
591
592        assert!(is_preformatted_html(&msg));
593    }
594}