1use 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
22const MAX_MESSAGE_CHARS: usize = 4000;
28
29fn 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(¤t) + 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(¤t) + 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(¶ms).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 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(¶ms).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)], ) -> 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(¶ms).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(¶ms).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 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 {
352 let mut off = offset.lock().await;
353 if update.id + 1 > *off {
354 *off = update.id + 1;
355 }
356 }
357
358 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 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 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 !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 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 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 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("&").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 < b && c > 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}