Skip to main content

formal_ai/
telegram.rs

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";
12/// Telegram rejects `sendMessage` payloads whose text exceeds 4096 characters,
13/// so the supplementary thinking blockquote (issue #488) is only appended when
14/// the whole reply still fits within this budget.
15const TELEGRAM_MAX_MESSAGE_LEN: usize = 4096;
16/// Crate version advertised by the `/version` bot command. Tracks
17/// `Cargo.toml` automatically so every release reports the right number
18/// without manual bumps (issue #72).
19const 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;
23/// Minimum gap between consecutive `editMessageText` calls on the same chat
24/// (issue #488). Telegram throttles bots to roughly one message-affecting
25/// operation per chat per second; this debounce keeps the progressive thinking
26/// stream within that budget without surprising the user with bursts.
27const TELEGRAM_THINKING_EDIT_DEBOUNCE_MS: u64 = 1_200;
28/// Cap on the number of intermediate edits we stream while the answer is being
29/// composed (issue #488). With the debounce above this keeps the visible
30/// "thinking" phase under ~5 s, matching the upper bound the issue asks for.
31const TELEGRAM_THINKING_MAX_EDITS: usize = 4;
32/// Initial placeholder shown the moment the thinking bubble appears on
33/// Telegram, before the first thinking step is rendered. Mirrors the web UI's
34/// "Reading the request…" pending phase (issue #488).
35const 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/// One debounced `editMessageText` call that progressively reveals the
47/// solver's thinking inside Telegram (issue #488).
48///
49/// Each edit replaces the live thinking message with a richer snapshot of the
50/// solver's reasoning; the runtime sleeps `delay_before_ms` between edits so
51/// the stream stays within Telegram's per-chat rate limits and still feels
52/// "alive" rather than a single instant dump.
53#[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    /// Encode the edit as the JSON body Telegram's `editMessageText` expects.
62    /// `chat_id` and `message_id` are supplied by the runtime once the initial
63    /// `sendMessage` reports the live thinking message's id.
64    #[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/// A Telegram `document` attachment (any uploaded file).
157#[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/// One size of a Telegram `photo`. Photos arrive as an array of increasing
168/// resolutions; the largest carries the most representative size metadata.
169#[derive(Debug, Deserialize)]
170struct TelegramPhotoSize {
171    #[serde(default)]
172    file_size: Option<u64>,
173}
174
175impl TelegramMessage {
176    /// Collect every attached file on this message as shared
177    /// [`Attachment`](crate::attachment_context::Attachment) metadata, so the
178    /// solver receives the same `Attached files:` context every other surface
179    /// builds. Telegram delivers the file's own caption separately, so no text
180    /// excerpt is available here — the solver still recognises the attachment by
181    /// name and MIME type and requests the local file text.
182    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    /// Convert a Telegram file object into a shared [`Attachment`], defaulting a
214    /// missing name to `<kind>_<message_id>` and a missing MIME type to
215    /// `fallback_mime` (or octet-stream when that is empty too).
216    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
305/// Final reply text plus the thinking steps that produced it (issue #488).
306///
307/// The polling code needs both the rendered answer (final edit) and the
308/// thinking steps (progressive intermediate edits), so this struct keeps them
309/// together and lets the solver run once per Telegram update.
310struct 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    // Fold any attached document/photo/media into the same `Attached files:`
318    // context every other surface builds, so an attachment-only message (or a
319    // caption plus a file) reaches the solver as a grounded prompt (issue #535).
320    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    // Append the collapsed thinking blockquote after the answer, but only while
345    // the whole reply (answer + thinking + trace footer) still fits inside
346    // Telegram's 4096-character limit; the reasoning is supplementary, so it is
347    // dropped rather than risk a rejected `sendMessage` (issue #488).
348    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
373/// Render the solver's concrete thinking steps as a Telegram expandable
374/// blockquote (issue #488).
375///
376/// Telegram's native `<blockquote expandable>` is collapsed by default and
377/// expands on tap, which is a direct, native fit for the issue's "show the
378/// reasoning, collapsed, with an expand affordance" requirement on a non-UI
379/// surface. The lines are the same concrete English meta-language descriptions
380/// every other surface renders (the CLI `--thinking` trace, the OpenAI and
381/// Anthropic APIs); the browser UI additionally translates them into the user's
382/// language through its i18n catalog. Returns `None` when there is nothing to
383/// show so callers can skip the separator entirely.
384fn 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('&', "&amp;")
469        .replace('<', "&lt;")
470        .replace('>', "&gt;")
471        .replace('"', "&quot;")
472}
473
474/// Options that control how the long-polling loop talks to Telegram.
475///
476/// `api_base` is the Telegram Bot API root (`<https://api.telegram.org>` by default).
477/// `token` is the bot token without the leading `bot` prefix.
478/// `timeout_seconds` is forwarded to Telegram's `getUpdates` long-polling timeout.
479/// `limit` is forwarded to Telegram's `getUpdates` limit parameter (1-100).
480/// `allowed_updates` is forwarded as JSON to Telegram so the bot can restrict the
481/// update types it receives.
482#[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/// A reply built from a Telegram update that should be sent back through `sendMessage`.
581///
582/// On chats where the bot can post follow-up messages (polling mode), the
583/// initial `sendMessage` posts the live thinking placeholder; the
584/// `progressive_edits` chain then progressively reveals more reasoning via
585/// `editMessageText` until the final edit replaces the bubble with the
586/// composed answer (issue #488). The runtime sleeps each edit's
587/// `delay_before_ms` between calls so the stream stays within Telegram's
588/// per-chat rate limits.
589#[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    /// Progressive `editMessageText` chain that walks the user through the
596    /// solver's reasoning before the final answer lands. Skipped during
597    /// serialization because it is runtime metadata, not a Telegram API field.
598    #[serde(skip)]
599    pub progressive_edits: Vec<TelegramThinkingEdit>,
600}
601
602impl TelegramPollingReply {
603    /// Encode the reply as the JSON body Telegram's `sendMessage` expects.
604    #[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/// Extract `result.message_id` from a Telegram `sendMessage` response body.
611///
612/// The runtime targets the extracted id with follow-up `editMessageText`
613/// calls (issue #488). Returns `None` when the response is missing/malformed —
614/// callers then skip the progressive thinking stream and keep the initial
615/// placeholder rather than risk garbled state.
616#[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/// Parsed slice of a Telegram `getUpdates` response.
627#[derive(Debug, Clone, PartialEq, Eq)]
628pub struct ParsedUpdatesBatch {
629    pub replies: Vec<TelegramPollingReply>,
630    pub next_offset: Option<i64>,
631}
632
633/// Parse a Telegram `getUpdates` response and convert each update into a reply.
634///
635/// The returned `next_offset` is `max(update_id) + 1` so the next `getUpdates`
636/// call only returns new updates.
637pub 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        // Issue #488: on the polling surface the live thinking placeholder is
686        // the initial message; the rendered answer arrives via the final
687        // `editMessageText`. When progressive edits are present the placeholder
688        // replaces the answer text in the initial `sendMessage` body.
689        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
700/// Build the progressive `editMessageText` chain (issue #488).
701///
702/// Splits the solver's thinking steps into at most
703/// `TELEGRAM_THINKING_MAX_EDITS` snapshots, where each snapshot reveals one
704/// more group of steps than the previous, then ends with a final edit that
705/// replaces the live bubble with the fully composed answer (which already
706/// carries the collapsed expandable blockquote when it fits within
707/// Telegram's 4096-character budget).
708///
709/// Returns an empty vector when there are no steps to stream so callers can
710/// stay on the single-`sendMessage` fast path.
711fn 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        // Edits whose text would exceed Telegram's 4096-char limit are
734        // dropped; the live bubble keeps the latest legal snapshot until the
735        // final answer lands.
736        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    // Final edit: hand off from the live thinking bubble to the composed
746    // answer (which already includes the collapsed thinking blockquote when
747    // it fits within Telegram's 4096-char budget).
748    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}