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