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::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
32const MAX_MESSAGE_CHARS: usize = 4000;
38
39fn 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(¤t) + 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(¤t) + 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(¶ms).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 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(¶ms).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)], ) -> 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(¶ms).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(¶ms).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 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 {
360 let mut off = offset.lock().await;
361 if update.id + 1 > *off {
362 *off = update.id + 1;
363 }
364 }
365
366 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 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 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 !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 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 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 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("&").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 < b && c > 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}