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