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