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