1use std::error::Error;
2use std::fmt::{Display, Formatter, Write};
3
4use serde::{Deserialize, Serialize};
5
6use crate::attachment_context::{compose_prompt_with_attachments, Attachment};
7use crate::engine::{naturalize_thinking_step, ThinkingStep};
8use crate::solver::{ExecutionSurface, SolverConfig, UniversalSolver};
9
10const TEXT_ONLY_MESSAGE: &str = "I can only process Telegram text messages in this implementation. Send a text prompt or a message caption.";
11const DEFAULT_API_BASE: &str = "https://api.telegram.org";
12const TELEGRAM_MAX_MESSAGE_LEN: usize = 4096;
16const FORMAL_AI_VERSION: &str = env!("CARGO_PKG_VERSION");
20const DEFAULT_POLL_TIMEOUT_SECONDS: u32 = 30;
21const DEFAULT_POLL_LIMIT: u32 = 100;
22const POLL_CONNECT_TIMEOUT_PADDING_SECONDS: u32 = 10;
23const TELEGRAM_THINKING_EDIT_DEBOUNCE_MS: u64 = 1_200;
28const TELEGRAM_THINKING_MAX_EDITS: usize = 4;
32const TELEGRAM_THINKING_INITIAL_PLACEHOLDER: &str = "<i>\u{1F4AD} Reading the request…</i>";
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
38pub struct TelegramWebhookReply {
39 pub method: &'static str,
40 pub chat_id: i64,
41 pub text: String,
42 pub parse_mode: &'static str,
43 pub reply_parameters: TelegramReplyParameters,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct TelegramThinkingEdit {
55 pub text: String,
56 pub parse_mode: &'static str,
57 pub delay_before_ms: u64,
58}
59
60impl TelegramThinkingEdit {
61 #[must_use]
65 pub fn to_edit_message_body(&self, chat_id: i64, message_id: i64) -> String {
66 let mut value = serde_json::Map::new();
67 value.insert(String::from("chat_id"), serde_json::Value::from(chat_id));
68 value.insert(
69 String::from("message_id"),
70 serde_json::Value::from(message_id),
71 );
72 value.insert(
73 String::from("text"),
74 serde_json::Value::from(self.text.clone()),
75 );
76 value.insert(
77 String::from("parse_mode"),
78 serde_json::Value::from(self.parse_mode),
79 );
80 serde_json::to_string(&serde_json::Value::Object(value))
81 .unwrap_or_else(|_| String::from("{}"))
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
86pub struct TelegramReplyParameters {
87 pub message_id: i64,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum TelegramWebhookError {
92 InvalidJson(String),
93}
94
95impl Display for TelegramWebhookError {
96 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
97 match self {
98 Self::InvalidJson(message) => {
99 write!(formatter, "invalid Telegram update JSON: {message}")
100 }
101 }
102 }
103}
104
105impl Error for TelegramWebhookError {}
106
107#[derive(Debug, Deserialize)]
108struct TelegramUpdate {
109 #[serde(default)]
110 update_id: Option<i64>,
111 #[serde(default)]
112 message: Option<TelegramMessage>,
113 #[serde(default)]
114 edited_message: Option<TelegramMessage>,
115 #[serde(default)]
116 channel_post: Option<TelegramMessage>,
117 #[serde(default)]
118 edited_channel_post: Option<TelegramMessage>,
119}
120
121impl TelegramUpdate {
122 fn into_message(self) -> Option<TelegramMessage> {
123 self.message
124 .or(self.edited_message)
125 .or(self.channel_post)
126 .or(self.edited_channel_post)
127 }
128}
129
130#[derive(Debug, Deserialize)]
131struct TelegramMessage {
132 message_id: i64,
133 chat: TelegramChat,
134 #[serde(default)]
135 text: Option<String>,
136 #[serde(default)]
137 caption: Option<String>,
138 #[serde(default)]
139 entities: Vec<TelegramEntity>,
140 #[serde(default)]
141 reply_to_message: Option<Box<Self>>,
142 #[serde(default)]
143 from: Option<TelegramUser>,
144 #[serde(default)]
145 document: Option<TelegramDocument>,
146 #[serde(default)]
147 photo: Vec<TelegramPhotoSize>,
148 #[serde(default)]
149 audio: Option<TelegramDocument>,
150 #[serde(default)]
151 voice: Option<TelegramDocument>,
152 #[serde(default)]
153 video: Option<TelegramDocument>,
154}
155
156#[derive(Debug, Deserialize)]
158struct TelegramDocument {
159 #[serde(default)]
160 file_name: Option<String>,
161 #[serde(default)]
162 mime_type: Option<String>,
163 #[serde(default)]
164 file_size: Option<u64>,
165}
166
167#[derive(Debug, Deserialize)]
170struct TelegramPhotoSize {
171 #[serde(default)]
172 file_size: Option<u64>,
173}
174
175impl TelegramMessage {
176 fn attachments(&self) -> Vec<Attachment> {
183 let mut attachments = Vec::new();
184 if let Some(document) = &self.document {
185 attachments.push(document.as_attachment("document", self.message_id, ""));
186 }
187 if let Some(audio) = &self.audio {
188 attachments.push(audio.as_attachment("audio", self.message_id, "audio/mpeg"));
189 }
190 if let Some(voice) = &self.voice {
191 attachments.push(voice.as_attachment("voice", self.message_id, "audio/ogg"));
192 }
193 if let Some(video) = &self.video {
194 attachments.push(video.as_attachment("video", self.message_id, "video/mp4"));
195 }
196 if !self.photo.is_empty() {
197 let largest = self
198 .photo
199 .iter()
200 .max_by_key(|size| size.file_size.unwrap_or(0));
201 let mut attachment =
202 Attachment::new(format!("photo_{}.jpg", self.message_id), "image/jpeg");
203 if let Some(size) = largest.and_then(|size| size.file_size) {
204 attachment = attachment.with_size(size);
205 }
206 attachments.push(attachment);
207 }
208 attachments
209 }
210}
211
212impl TelegramDocument {
213 fn as_attachment(&self, kind: &str, message_id: i64, fallback_mime: &str) -> Attachment {
217 let name = self
218 .file_name
219 .clone()
220 .filter(|name| !name.trim().is_empty())
221 .unwrap_or_else(|| format!("{kind}_{message_id}"));
222 let mime = self
223 .mime_type
224 .clone()
225 .filter(|mime| !mime.trim().is_empty())
226 .unwrap_or_else(|| fallback_mime.to_owned());
227 let mut attachment = Attachment::new(name, mime);
228 if let Some(size) = self.file_size {
229 attachment = attachment.with_size(size);
230 }
231 attachment
232 }
233}
234
235#[derive(Debug, Deserialize)]
236struct TelegramChat {
237 id: i64,
238 #[serde(default, rename = "type")]
239 kind: Option<String>,
240 #[serde(default)]
241 title: Option<String>,
242}
243
244#[derive(Debug, Deserialize)]
245struct TelegramEntity {
246 #[serde(rename = "type")]
247 kind: String,
248}
249
250#[derive(Debug, Deserialize)]
251struct TelegramUser {
252 #[serde(default)]
253 is_bot: bool,
254}
255
256fn message_is_in_public_chat(chat: &TelegramChat) -> bool {
257 matches!(
258 chat.kind.as_deref(),
259 Some("group" | "supergroup" | "channel")
260 )
261}
262
263fn message_addresses_bot(message: &TelegramMessage) -> bool {
264 if message
265 .entities
266 .iter()
267 .any(|entity| entity.kind == "mention" || entity.kind == "bot_command")
268 {
269 return true;
270 }
271 if let Some(reply) = &message.reply_to_message {
272 if reply.from.as_ref().is_some_and(|user| user.is_bot) {
273 return true;
274 }
275 }
276 if let Some(title) = &message.chat.title {
277 let lower = title.to_lowercase();
278 if lower.contains("formal") || lower.contains("formal_ai") || lower.contains("formal-ai") {
279 return true;
280 }
281 }
282 false
283}
284
285pub fn handle_telegram_webhook(
286 body: &str,
287) -> Result<Option<TelegramWebhookReply>, TelegramWebhookError> {
288 let update = serde_json::from_str::<TelegramUpdate>(body)
289 .map_err(|error| TelegramWebhookError::InvalidJson(error.to_string()))?;
290 let Some(message) = update.into_message() else {
291 return Ok(None);
292 };
293
294 if message_is_in_public_chat(&message.chat) && !message_addresses_bot(&message) {
295 return Ok(None);
296 }
297
298 Ok(Some(reply_for_message(&message)))
299}
300
301fn reply_for_message(message: &TelegramMessage) -> TelegramWebhookReply {
302 compose_telegram_reply(message).reply
303}
304
305struct TelegramReplyBundle {
311 reply: TelegramWebhookReply,
312 thinking_steps: Vec<ThinkingStep>,
313}
314
315fn compose_telegram_reply(message: &TelegramMessage) -> TelegramReplyBundle {
316 let raw_text = message.text.as_deref().or(message.caption.as_deref());
317 let attachments = message.attachments();
321 let prompt = compose_prompt_with_attachments(raw_text, &attachments);
322
323 let (reply_text, trace_id, thinking, steps) =
324 prompt.filter(|text| !text.trim().is_empty()).map_or_else(
325 || (String::from(TEXT_ONLY_MESSAGE), None, None, Vec::new()),
326 |prompt| {
327 let trimmed = prompt.trim();
328 if is_version_command(trimmed) {
329 return (version_reply_text(), None, None, Vec::new());
330 }
331 let symbolic = telegram_solver().solve(trimmed);
332 let trace = symbolic
333 .evidence_links
334 .iter()
335 .find_map(|link| link.strip_prefix("trace:").map(str::to_owned));
336 let thinking = telegram_thinking_blockquote(&symbolic.thinking_steps);
337 (symbolic.answer, trace, thinking, symbolic.thinking_steps)
338 },
339 );
340
341 let mut text = telegram_html_from_markdown(&reply_text);
342 let trace_footer = trace_id.map(|trace| format!("\n\n/trace {trace}"));
343
344 if let Some(thinking) = thinking {
349 let trace_len = trace_footer.as_deref().map_or(0, str::len);
350 if text.len() + "\n\n".len() + thinking.len() + trace_len <= TELEGRAM_MAX_MESSAGE_LEN {
351 text.push_str("\n\n");
352 text.push_str(&thinking);
353 }
354 }
355 if let Some(footer) = trace_footer {
356 text.push_str(&footer);
357 }
358
359 TelegramReplyBundle {
360 reply: TelegramWebhookReply {
361 method: "sendMessage",
362 chat_id: message.chat.id,
363 text,
364 parse_mode: "HTML",
365 reply_parameters: TelegramReplyParameters {
366 message_id: message.message_id,
367 },
368 },
369 thinking_steps: steps,
370 }
371}
372
373fn telegram_thinking_blockquote(steps: &[ThinkingStep]) -> Option<String> {
385 if steps.is_empty() {
386 return None;
387 }
388 let mut body = String::new();
389 for step in steps {
390 let sentence = if step.summary.is_empty() {
391 naturalize_thinking_step(&step.step, &step.detail)
392 } else {
393 step.summary.clone()
394 };
395 if !body.is_empty() {
396 body.push('\n');
397 }
398 body.push_str(&html_escape(&sentence));
399 }
400 Some(format!("<blockquote expandable>💭 {body}</blockquote>"))
401}
402
403fn telegram_solver() -> UniversalSolver {
404 let mut config = SolverConfig::from_env();
405 config.execution_surface = ExecutionSurface::Telegram;
406 UniversalSolver::new(config)
407}
408
409fn is_version_command(text: &str) -> bool {
410 let first_token = text.split_whitespace().next().unwrap_or("");
411 let command = first_token.split('@').next().unwrap_or("");
412 command.eq_ignore_ascii_case("/version")
413}
414
415fn version_reply_text() -> String {
416 format!("formal-ai {FORMAL_AI_VERSION}")
417}
418
419#[must_use]
420pub fn telegram_html_from_markdown(markdown: &str) -> String {
421 let mut rendered = String::new();
422 let mut in_code_block = false;
423
424 for line in markdown.lines() {
425 let trimmed = line.trim_start();
426 if let Some(language) = trimmed.strip_prefix("```") {
427 if in_code_block {
428 rendered.push_str("</code></pre>\n");
429 in_code_block = false;
430 } else {
431 rendered.push_str(&open_pre_code_tag(language.trim()));
432 in_code_block = true;
433 }
434 continue;
435 }
436
437 rendered.push_str(&html_escape(line));
438 rendered.push('\n');
439 }
440
441 if in_code_block {
442 rendered.push_str("</code></pre>\n");
443 }
444
445 rendered.trim_end().to_owned()
446}
447
448fn open_pre_code_tag(language: &str) -> String {
449 language_class(language).map_or_else(
450 || String::from("<pre><code>"),
451 |class| format!("<pre><code class=\"{class}\">"),
452 )
453}
454
455fn language_class(language: &str) -> Option<String> {
456 if language.is_empty()
457 || !language
458 .chars()
459 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
460 {
461 return None;
462 }
463
464 Some(format!("language-{language}"))
465}
466
467fn html_escape(text: &str) -> String {
468 text.replace('&', "&")
469 .replace('<', "<")
470 .replace('>', ">")
471 .replace('"', """)
472}
473
474#[derive(Debug, Clone)]
483pub struct TelegramPollingConfig {
484 pub api_base: String,
485 pub token: String,
486 pub timeout_seconds: u32,
487 pub limit: u32,
488 pub allowed_updates: Vec<String>,
489}
490
491impl TelegramPollingConfig {
492 #[must_use]
493 pub fn new(token: impl Into<String>) -> Self {
494 Self {
495 api_base: String::from(DEFAULT_API_BASE),
496 token: token.into(),
497 timeout_seconds: DEFAULT_POLL_TIMEOUT_SECONDS,
498 limit: DEFAULT_POLL_LIMIT,
499 allowed_updates: Vec::new(),
500 }
501 }
502
503 #[must_use]
504 pub fn get_updates_url(&self, offset: Option<i64>) -> String {
505 let mut url = format!(
506 "{}/bot{}/getUpdates?timeout={}&limit={}",
507 self.api_base.trim_end_matches('/'),
508 self.token,
509 self.timeout_seconds,
510 self.limit
511 );
512 if let Some(offset_value) = offset {
513 let _ = write!(url, "&offset={offset_value}");
514 }
515 if !self.allowed_updates.is_empty() {
516 let encoded = url_encode(&serialize_string_array(&self.allowed_updates));
517 let _ = write!(url, "&allowed_updates={encoded}");
518 }
519 url
520 }
521
522 #[must_use]
523 pub fn send_message_url(&self) -> String {
524 format!(
525 "{}/bot{}/sendMessage",
526 self.api_base.trim_end_matches('/'),
527 self.token
528 )
529 }
530
531 #[must_use]
532 pub fn edit_message_text_url(&self) -> String {
533 format!(
534 "{}/bot{}/editMessageText",
535 self.api_base.trim_end_matches('/'),
536 self.token
537 )
538 }
539
540 #[must_use]
541 pub const fn http_timeout_seconds(&self) -> u32 {
542 self.timeout_seconds
543 .saturating_add(POLL_CONNECT_TIMEOUT_PADDING_SECONDS)
544 }
545}
546
547#[derive(Debug, Clone, PartialEq, Eq)]
548pub enum TelegramPollingError {
549 InvalidJson(String),
550 UnexpectedResponse(String),
551}
552
553impl Display for TelegramPollingError {
554 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
555 match self {
556 Self::InvalidJson(message) => {
557 write!(formatter, "invalid Telegram getUpdates JSON: {message}")
558 }
559 Self::UnexpectedResponse(message) => {
560 write!(
561 formatter,
562 "unexpected Telegram getUpdates payload: {message}"
563 )
564 }
565 }
566 }
567}
568
569impl Error for TelegramPollingError {}
570
571#[derive(Debug, Deserialize)]
572struct GetUpdatesResponse {
573 ok: bool,
574 #[serde(default)]
575 description: Option<String>,
576 #[serde(default)]
577 result: Option<Vec<serde_json::Value>>,
578}
579
580#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
590pub struct TelegramPollingReply {
591 pub chat_id: i64,
592 pub text: String,
593 pub parse_mode: &'static str,
594 pub reply_parameters: TelegramReplyParameters,
595 #[serde(skip)]
599 pub progressive_edits: Vec<TelegramThinkingEdit>,
600}
601
602impl TelegramPollingReply {
603 #[must_use]
605 pub fn to_send_message_body(&self) -> String {
606 serde_json::to_string(self).unwrap_or_else(|_| String::from("{}"))
607 }
608}
609
610#[must_use]
617pub fn extract_sent_message_id(body: &str) -> Option<i64> {
618 let value: serde_json::Value = serde_json::from_str(body).ok()?;
619 value.get("ok").and_then(serde_json::Value::as_bool)?;
620 value
621 .get("result")
622 .and_then(|result| result.get("message_id"))
623 .and_then(serde_json::Value::as_i64)
624}
625
626#[derive(Debug, Clone, PartialEq, Eq)]
628pub struct ParsedUpdatesBatch {
629 pub replies: Vec<TelegramPollingReply>,
630 pub next_offset: Option<i64>,
631}
632
633pub fn parse_get_updates_response(body: &str) -> Result<ParsedUpdatesBatch, TelegramPollingError> {
638 let response = serde_json::from_str::<GetUpdatesResponse>(body)
639 .map_err(|error| TelegramPollingError::InvalidJson(error.to_string()))?;
640
641 if !response.ok {
642 let description = response
643 .description
644 .unwrap_or_else(|| String::from("Telegram reported ok=false"));
645 return Err(TelegramPollingError::UnexpectedResponse(description));
646 }
647
648 let updates = response.result.unwrap_or_default();
649 let mut replies = Vec::new();
650 let mut highest_update_id: Option<i64> = None;
651
652 for raw in updates {
653 let update_text = raw.to_string();
654 let parsed = serde_json::from_value::<TelegramUpdate>(raw)
655 .map_err(|error| TelegramPollingError::InvalidJson(error.to_string()))?;
656 if let Some(id) = parsed.update_id {
657 highest_update_id =
658 Some(highest_update_id.map_or(id, |existing| std::cmp::max(existing, id)));
659 }
660
661 if let Some(message) = parsed.into_message() {
662 replies.push(reply_for_polling_message(&message));
663 } else {
664 eprintln!(
665 "telegram-poll: ignoring update without a supported message field: {update_text}"
666 );
667 }
668 }
669
670 Ok(ParsedUpdatesBatch {
671 replies,
672 next_offset: highest_update_id.map(|id| id + 1),
673 })
674}
675
676fn reply_for_polling_message(message: &TelegramMessage) -> TelegramPollingReply {
677 let bundle = compose_telegram_reply(message);
678 let progressive_edits = build_progressive_thinking_edits(
679 &bundle.thinking_steps,
680 bundle.reply.text.clone(),
681 bundle.reply.parse_mode,
682 );
683 TelegramPollingReply {
684 chat_id: bundle.reply.chat_id,
685 text: if progressive_edits.is_empty() {
690 bundle.reply.text
691 } else {
692 String::from(TELEGRAM_THINKING_INITIAL_PLACEHOLDER)
693 },
694 parse_mode: bundle.reply.parse_mode,
695 reply_parameters: bundle.reply.reply_parameters,
696 progressive_edits,
697 }
698}
699
700fn build_progressive_thinking_edits(
712 steps: &[ThinkingStep],
713 final_text: String,
714 parse_mode: &'static str,
715) -> Vec<TelegramThinkingEdit> {
716 if steps.is_empty() {
717 return Vec::new();
718 }
719
720 let mut edits = Vec::new();
721 let total = steps.len();
722 let cap = TELEGRAM_THINKING_MAX_EDITS.min(total);
723 let mut last_visible = 0;
724 for snapshot_index in 0..cap {
725 let visible = ((snapshot_index + 1) * total).div_ceil(cap).min(total);
726 if visible == last_visible {
727 continue;
728 }
729 last_visible = visible;
730 let Some(text) = telegram_thinking_blockquote(&steps[..visible]) else {
731 continue;
732 };
733 if text.len() > TELEGRAM_MAX_MESSAGE_LEN {
737 continue;
738 }
739 edits.push(TelegramThinkingEdit {
740 text,
741 parse_mode,
742 delay_before_ms: TELEGRAM_THINKING_EDIT_DEBOUNCE_MS,
743 });
744 }
745 edits.push(TelegramThinkingEdit {
749 text: final_text,
750 parse_mode,
751 delay_before_ms: TELEGRAM_THINKING_EDIT_DEBOUNCE_MS,
752 });
753 edits
754}
755
756fn serialize_string_array(values: &[String]) -> String {
757 let mut buffer = String::from("[");
758 for (index, value) in values.iter().enumerate() {
759 if index > 0 {
760 buffer.push(',');
761 }
762 buffer.push('"');
763 for character in value.chars() {
764 match character {
765 '"' | '\\' => {
766 buffer.push('\\');
767 buffer.push(character);
768 }
769 _ => buffer.push(character),
770 }
771 }
772 buffer.push('"');
773 }
774 buffer.push(']');
775 buffer
776}
777
778fn url_encode(input: &str) -> String {
779 let mut encoded = String::with_capacity(input.len());
780 for byte in input.bytes() {
781 let character = byte as char;
782 if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.' | '~') {
783 encoded.push(character);
784 } else {
785 let _ = write!(encoded, "%{byte:02X}");
786 }
787 }
788 encoded
789}