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