Skip to main content

formal_ai/
telegram_runtime.rs

1use std::error::Error;
2use std::fmt::{Display, Formatter};
3use std::io;
4use std::process::Command;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::sync::Arc;
7use std::thread::sleep;
8use std::time::Duration;
9
10use crate::server::serve;
11use crate::telegram::{
12    extract_sent_message_id, parse_get_updates_response, TelegramPollingConfig,
13    TelegramPollingError, TelegramPollingReply,
14};
15
16/// Errors that can interrupt the long-polling loop.
17#[derive(Debug)]
18pub enum TelegramPollingRuntimeError {
19    Transport(String),
20    Polling(TelegramPollingError),
21}
22
23impl Display for TelegramPollingRuntimeError {
24    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
25        match self {
26            Self::Transport(message) => write!(formatter, "telegram transport error: {message}"),
27            Self::Polling(error) => write!(formatter, "telegram polling error: {error}"),
28        }
29    }
30}
31
32impl Error for TelegramPollingRuntimeError {}
33
34impl From<TelegramPollingError> for TelegramPollingRuntimeError {
35    fn from(value: TelegramPollingError) -> Self {
36        Self::Polling(value)
37    }
38}
39
40/// HTTP transport used by the long-polling loop. The trait keeps the loop
41/// testable without touching the real Telegram API.
42pub trait TelegramTransport {
43    /// Issue a `getUpdates` GET request and return the raw JSON body.
44    fn get_updates(&mut self, url: &str) -> Result<String, TelegramPollingRuntimeError>;
45    /// Issue a `sendMessage` POST request with a JSON body and return the raw response body.
46    fn send_message(
47        &mut self,
48        url: &str,
49        body: &str,
50    ) -> Result<String, TelegramPollingRuntimeError>;
51    /// Issue an `editMessageText` POST request with a JSON body and return the raw response
52    /// body. Powers the progressive thinking-message stream introduced by issue #488. The
53    /// default implementation falls back to `send_message`, so existing transports keep
54    /// working — they just deliver the edit as a regular `sendMessage` on a different URL,
55    /// which is harmless during tests that only inspect the recorded URL/body pairs.
56    fn edit_message_text(
57        &mut self,
58        url: &str,
59        body: &str,
60    ) -> Result<String, TelegramPollingRuntimeError> {
61        self.send_message(url, body)
62    }
63    /// Sleep for the given duration between progressive thinking edits so the
64    /// runtime respects Telegram's per-chat rate limits (issue #488). The
65    /// default implementation uses `std::thread::sleep`; tests override it to
66    /// keep the suite instant.
67    fn sleep_between_edits(&mut self, duration: Duration) {
68        sleep(duration);
69    }
70}
71
72/// Default transport that shells out to `curl` so the binary does not need a TLS dependency.
73pub struct CurlTelegramTransport {
74    http_timeout_seconds: u32,
75}
76
77impl CurlTelegramTransport {
78    #[must_use]
79    pub const fn new(http_timeout_seconds: u32) -> Self {
80        Self {
81            http_timeout_seconds,
82        }
83    }
84
85    fn run_curl(args: &[&str]) -> Result<String, TelegramPollingRuntimeError> {
86        let output = Command::new("curl").args(args).output().map_err(|error| {
87            if error.kind() == io::ErrorKind::NotFound {
88                TelegramPollingRuntimeError::Transport(String::from(
89                    "curl is required for the Telegram polling client; install curl and retry",
90                ))
91            } else {
92                TelegramPollingRuntimeError::Transport(error.to_string())
93            }
94        })?;
95
96        if output.status.success() {
97            Ok(String::from_utf8_lossy(&output.stdout).into_owned())
98        } else {
99            let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
100            Err(TelegramPollingRuntimeError::Transport(format!(
101                "curl exited with {status}: {stderr}",
102                status = output.status,
103            )))
104        }
105    }
106}
107
108impl TelegramTransport for CurlTelegramTransport {
109    fn get_updates(&mut self, url: &str) -> Result<String, TelegramPollingRuntimeError> {
110        let timeout = self.http_timeout_seconds.to_string();
111        let args = [
112            "--silent",
113            "--show-error",
114            "--fail",
115            "--max-time",
116            &timeout,
117            url,
118        ];
119        Self::run_curl(&args)
120    }
121
122    fn send_message(
123        &mut self,
124        url: &str,
125        body: &str,
126    ) -> Result<String, TelegramPollingRuntimeError> {
127        let timeout = self.http_timeout_seconds.to_string();
128        let args = [
129            "--silent",
130            "--show-error",
131            "--fail",
132            "--max-time",
133            &timeout,
134            "-H",
135            "content-type: application/json",
136            "-X",
137            "POST",
138            "-d",
139            body,
140            url,
141        ];
142        Self::run_curl(&args)
143    }
144
145    fn edit_message_text(
146        &mut self,
147        url: &str,
148        body: &str,
149    ) -> Result<String, TelegramPollingRuntimeError> {
150        // `editMessageText` shares the same POST/JSON shape as `sendMessage`; the
151        // only differences are the URL and the payload fields (`message_id`
152        // instead of `reply_parameters.message_id`).
153        self.send_message(url, body)
154    }
155}
156
157/// Start the long-polling loop with the default curl transport.
158pub fn run_telegram_polling(
159    config: &TelegramPollingConfig,
160    initial_offset: Option<i64>,
161    cancellation: Arc<AtomicBool>,
162) -> Result<(), TelegramPollingRuntimeError> {
163    let mut transport = CurlTelegramTransport::new(config.http_timeout_seconds());
164    run_telegram_polling_with_transport(config, initial_offset, cancellation, &mut transport)
165}
166
167/// Start the long-polling loop with an injected transport (used in tests).
168#[allow(clippy::needless_pass_by_value)]
169pub fn run_telegram_polling_with_transport<T: TelegramTransport>(
170    config: &TelegramPollingConfig,
171    initial_offset: Option<i64>,
172    cancellation: Arc<AtomicBool>,
173    transport: &mut T,
174) -> Result<(), TelegramPollingRuntimeError> {
175    eprintln!(
176        "formal-ai telegram polling started: api_base={} timeout={}s limit={}",
177        config.api_base, config.timeout_seconds, config.limit
178    );
179
180    // The polling loop is a long-lived runtime just like `serve()`: start the
181    // idle-time dreaming worker so a Telegram-only deployment also keeps
182    // learning from its memory log (issue #540 §6).
183    crate::dreaming_runtime::start_core_dreaming();
184
185    let mut offset = initial_offset;
186
187    while !cancellation.load(Ordering::Relaxed) {
188        let updates_url = config.get_updates_url(offset);
189        let body = match transport.get_updates(&updates_url) {
190            Ok(body) => body,
191            Err(error) => {
192                eprintln!("telegram-poll: getUpdates failed: {error}");
193                sleep_with_cancellation(Duration::from_secs(1), &cancellation);
194                continue;
195            }
196        };
197
198        let batch = match parse_get_updates_response(&body) {
199            Ok(batch) => batch,
200            Err(error) => {
201                eprintln!("telegram-poll: invalid getUpdates response: {error}");
202                sleep_with_cancellation(Duration::from_secs(1), &cancellation);
203                continue;
204            }
205        };
206
207        if let Some(next_offset) = batch.next_offset {
208            offset = Some(next_offset);
209        }
210
211        if !batch.replies.is_empty() {
212            // Replying to live users is foreground work: hold the activity
213            // guard so the dreaming worker yields for the idle threshold.
214            let _foreground_activity = crate::dreaming_runtime::ForegroundActivity::begin();
215            for reply in &batch.replies {
216                send_reply(config, transport, reply);
217            }
218        }
219    }
220
221    eprintln!("formal-ai telegram polling stopped");
222    Ok(())
223}
224
225/// Run the existing HTTP webhook server (delegates to `serve`).
226pub fn run_telegram_webhook_server(address: &str) -> io::Result<()> {
227    serve(address)
228}
229
230fn send_reply<T: TelegramTransport>(
231    config: &TelegramPollingConfig,
232    transport: &mut T,
233    reply: &TelegramPollingReply,
234) {
235    let send_url = config.send_message_url();
236    let body = reply.to_send_message_body();
237    let send_response = match transport.send_message(&send_url, &body) {
238        Ok(response) => {
239            eprintln!(
240                "telegram-poll: sent reply to chat_id={} (message_id={})",
241                reply.chat_id, reply.reply_parameters.message_id
242            );
243            response
244        }
245        Err(error) => {
246            eprintln!(
247                "telegram-poll: sendMessage to chat_id={} failed: {error}",
248                reply.chat_id
249            );
250            return;
251        }
252    };
253
254    // Issue #488: stream the progressive thinking edits if Telegram echoed back
255    // a message_id we can target. Missing/unknown ids drop the stream silently
256    // and leave the initial bubble as the user's last view of the reply.
257    if reply.progressive_edits.is_empty() {
258        return;
259    }
260    let Some(sent_message_id) = extract_sent_message_id(&send_response) else {
261        eprintln!(
262            "telegram-poll: no message_id in sendMessage response for chat_id={}; skipping {} thinking edit(s)",
263            reply.chat_id,
264            reply.progressive_edits.len()
265        );
266        return;
267    };
268    let edit_url = config.edit_message_text_url();
269    for (index, edit) in reply.progressive_edits.iter().enumerate() {
270        if edit.delay_before_ms > 0 {
271            transport.sleep_between_edits(Duration::from_millis(edit.delay_before_ms));
272        }
273        let edit_body = edit.to_edit_message_body(reply.chat_id, sent_message_id);
274        match transport.edit_message_text(&edit_url, &edit_body) {
275            Ok(_) => {
276                eprintln!(
277                    "telegram-poll: edit {n}/{total} for chat_id={chat} message_id={msg}",
278                    n = index + 1,
279                    total = reply.progressive_edits.len(),
280                    chat = reply.chat_id,
281                    msg = sent_message_id,
282                );
283            }
284            Err(error) => {
285                eprintln!(
286                    "telegram-poll: editMessageText {n}/{total} for chat_id={chat} message_id={msg} failed: {error}",
287                    n = index + 1,
288                    total = reply.progressive_edits.len(),
289                    chat = reply.chat_id,
290                    msg = sent_message_id,
291                );
292                // Stop streaming if Telegram rejected an edit; the live bubble
293                // already shows the last successful snapshot.
294                return;
295            }
296        }
297    }
298}
299
300fn sleep_with_cancellation(total: Duration, cancellation: &AtomicBool) {
301    let step = Duration::from_millis(200);
302    let mut remaining = total;
303    while remaining > Duration::ZERO {
304        if cancellation.load(Ordering::Relaxed) {
305            return;
306        }
307        let sleep_for = std::cmp::min(step, remaining);
308        sleep(sleep_for);
309        remaining = remaining.saturating_sub(sleep_for);
310    }
311}