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