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#[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
40pub trait TelegramTransport {
43 fn get_updates(&mut self, url: &str) -> Result<String, TelegramPollingRuntimeError>;
45 fn send_message(
47 &mut self,
48 url: &str,
49 body: &str,
50 ) -> Result<String, TelegramPollingRuntimeError>;
51 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 fn sleep_between_edits(&mut self, duration: Duration) {
68 sleep(duration);
69 }
70}
71
72pub 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 self.send_message(url, body)
154 }
155}
156
157pub 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#[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 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 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
225pub 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 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 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}