1use 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
19fn html_escape(s: &str) -> String {
24 s.replace('&', "&")
25 .replace('<', "<")
26 .replace('>', ">")
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(¶ms).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 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(¶ms).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)], ) -> 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(¶ms).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(¶ms).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 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 {
311 let mut off = offset.lock().await;
312 if update.update_id + 1 > *off {
313 *off = update.update_id + 1;
314 }
315 }
316
317 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 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 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 !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 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 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 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 < b && c > 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}