Skip to main content

agentmux_srv/messaging/telegram/
rest.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Telegram Bot API REST client — `getUpdates` long-polling plus
5//! `sendMessage`/`editMessageText` for outbound delivery.
6//!
7//! Rate limiting: v1 implements only per-chat 1 msg/s spacing (enforced by
8//! the caller in `poller.rs`) and reactive `429`/`retry_after` backoff. Full
9//! token-bucket accounting for the 20/min-per-group and ~30/s-global tiers is
10//! explicitly out of scope (spec §2.5) — mirrors Discord's `rest.rs`, which
11//! logs 429s and returns an error rather than doing header-driven throttling.
12//!
13//! Never log the full request URL: the bot token is embedded in the path
14//! (`https://api.telegram.org/bot{TOKEN}/...`), unlike Discord's
15//! `Authorization` header. Log method names only, never the URL.
16
17use std::time::Duration;
18
19use super::types::{EditMessageTextBody, Message, SendMessageBody, TelegramResponse};
20
21const TELEGRAM_API: &str = "https://api.telegram.org";
22
23/// Telegram's own long-poll wait, in seconds (passed as the `timeout` query param).
24const POLL_TIMEOUT_SECS: u64 = 30;
25/// HTTP client timeout for `getUpdates` — must exceed `POLL_TIMEOUT_SECS` with
26/// margin, or the client aborts the request right as data would have arrived
27/// (spec §9 — long-poll timeout vs HTTP client timeout mismatch).
28const POLL_CLIENT_TIMEOUT_SECS: u64 = 35;
29/// Per-request timeout for send/edit calls. Inbound polling and outbound
30/// sending run as fully independent tasks (poller.rs), so a stuck send can no
31/// longer block polling — this timeout instead bounds how long a single
32/// outbound send can delay the *next* queued send on the same task.
33const SEND_TIMEOUT_SECS: u64 = 10;
34
35/// Error from a Telegram Bot API call, carrying enough of the `429` envelope
36/// for the caller to implement per-chat backoff (spec §2.5).
37#[derive(Debug, Clone)]
38pub struct TelegramApiError {
39    pub message: String,
40    /// Present on 429 responses: seconds to wait before retrying.
41    pub retry_after: Option<u64>,
42    /// True when the 429's `parameters.scope` is `"chat"` — pause only that
43    /// chat's queue. False/absent → treat as a global-scope limit.
44    pub chat_scoped: bool,
45}
46
47impl std::fmt::Display for TelegramApiError {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        write!(f, "{}", self.message)
50    }
51}
52
53impl TelegramApiError {
54    fn simple(message: impl Into<String>) -> Self {
55        TelegramApiError {
56            message: message.into(),
57            retry_after: None,
58            chat_scoped: false,
59        }
60    }
61}
62
63/// Strips the bot token out of an error message before it's ever logged or
64/// returned. `reqwest::Error`'s `Display` embeds the request URL on
65/// send/connection failures, and the URL contains `bot{token}` — so `{e}`
66/// must never reach a log line or a `Result<_, String>` unredacted. This is
67/// the single choke point every error-construction site routes through, so
68/// callers can't accidentally skip it.
69fn redact(token: &str, s: String) -> String {
70    s.replace(token, "***")
71}
72
73/// Build a dedicated client for `getUpdates` with a timeout that safely
74/// exceeds Telegram's own 30s long-poll wait. Kept distinct from the client
75/// used for sends (spec §9).
76pub fn build_poll_client() -> reqwest::Client {
77    reqwest::Client::builder()
78        .timeout(Duration::from_secs(POLL_CLIENT_TIMEOUT_SECS))
79        .build()
80        .unwrap_or_else(|_| reqwest::Client::new())
81}
82
83/// GET /bot{token}/getUpdates
84///
85/// Returns raw JSON values rather than typed `Update`s deliberately: a single
86/// malformed update must not abort the whole batch (spec §9), so per-item
87/// typed parsing happens in `poller.rs`'s loop, where a parse failure on one
88/// item can be logged and skipped without losing the `update_id` needed to
89/// advance the offset past it.
90pub async fn get_updates(
91    http: &reqwest::Client,
92    token: &str,
93    offset: i64,
94) -> Result<Vec<serde_json::Value>, String> {
95    let url = format!("{TELEGRAM_API}/bot{token}/getUpdates");
96    let resp = http
97        .get(&url)
98        .query(&[
99            ("timeout", POLL_TIMEOUT_SECS.to_string()),
100            ("offset", offset.to_string()),
101            (
102                "allowed_updates",
103                "[\"message\",\"callback_query\"]".to_string(),
104            ),
105        ])
106        .send()
107        .await
108        .map_err(|e| redact(token, format!("telegram rest: getUpdates http error: {e}")))?;
109
110    let status = resp.status();
111    if status == reqwest::StatusCode::CONFLICT {
112        return Err(
113            "telegram: another getUpdates poller is active for this token (409) — check for a duplicate AgentMux instance or a registered webhook"
114                .to_string(),
115        );
116    }
117
118    let body: TelegramResponse<Vec<serde_json::Value>> = resp
119        .json()
120        .await
121        .map_err(|e| redact(token, format!("telegram rest: getUpdates parse error: {e}")))?;
122
123    if !body.ok {
124        return Err(format!(
125            "telegram rest: getUpdates failed: {}",
126            body.description.unwrap_or_else(|| "unknown error".to_string())
127        ));
128    }
129
130    Ok(body.result.unwrap_or_default())
131}
132
133/// POST /bot{token}/sendMessage
134pub async fn send_message(
135    http: &reqwest::Client,
136    token: &str,
137    chat_id: i64,
138    text: &str,
139) -> Result<Message, TelegramApiError> {
140    let url = format!("{TELEGRAM_API}/bot{token}/sendMessage");
141    let body = SendMessageBody {
142        chat_id,
143        text: escape_html(text),
144        parse_mode: "HTML",
145    };
146    post_for_message(http, token, &url, &body, "sendMessage").await
147}
148
149/// POST /bot{token}/editMessageText
150pub async fn edit_message_text(
151    http: &reqwest::Client,
152    token: &str,
153    chat_id: i64,
154    message_id: i64,
155    text: &str,
156) -> Result<Message, TelegramApiError> {
157    let url = format!("{TELEGRAM_API}/bot{token}/editMessageText");
158    let body = EditMessageTextBody {
159        chat_id,
160        message_id,
161        text: escape_html(text),
162        parse_mode: "HTML",
163    };
164    post_for_message(http, token, &url, &body, "editMessageText").await
165}
166
167/// Dispatches to `send_message` or `edit_message_text` depending on whether
168/// `edit_message_id` is set (spec §2.3 — streaming-output simulation).
169pub async fn send_or_edit(
170    http: &reqwest::Client,
171    token: &str,
172    chat_id: i64,
173    text: &str,
174    edit_message_id: Option<i64>,
175) -> Result<Message, TelegramApiError> {
176    match edit_message_id {
177        Some(message_id) => edit_message_text(http, token, chat_id, message_id, text).await,
178        None => send_message(http, token, chat_id, text).await,
179    }
180}
181
182async fn post_for_message<B: serde::Serialize>(
183    http: &reqwest::Client,
184    token: &str,
185    url: &str,
186    body: &B,
187    method_name: &str,
188) -> Result<Message, TelegramApiError> {
189    let resp = http
190        .post(url)
191        .timeout(Duration::from_secs(SEND_TIMEOUT_SECS))
192        .json(body)
193        .send()
194        .await
195        .map_err(|e| {
196            TelegramApiError::simple(redact(token, format!("telegram rest: {method_name} http error: {e}")))
197        })?;
198
199    let status = resp.status();
200
201    let parsed: TelegramResponse<Message> = resp.json().await.map_err(|e| {
202        TelegramApiError::simple(redact(token, format!("telegram rest: {method_name} parse error: {e}")))
203    })?;
204
205    if status == reqwest::StatusCode::TOO_MANY_REQUESTS || !parsed.ok {
206        let retry_after = parsed.parameters.as_ref().and_then(|p| p.retry_after);
207        let chat_scoped = parsed
208            .parameters
209            .as_ref()
210            .and_then(|p| p.scope.as_deref())
211            .map(|s| s == "chat")
212            .unwrap_or(false);
213        return Err(TelegramApiError {
214            message: format!(
215                "telegram rest: {method_name} failed ({status}): {}",
216                parsed.description.unwrap_or_else(|| "unknown error".to_string())
217            ),
218            retry_after,
219            chat_scoped,
220        });
221    }
222
223    parsed
224        .result
225        .ok_or_else(|| TelegramApiError::simple(format!("telegram rest: {method_name} ok but no result")))
226}
227
228/// Escapes the three characters HTML `parse_mode` treats specially. Per spec
229/// §2.2, Telegram's HTML mode only requires escaping `<`, `>`, `&` — simpler
230/// than MarkdownV2. Order matters: `&` must be escaped first so the escape
231/// sequences for `<`/`>` aren't themselves re-escaped.
232pub fn escape_html(text: &str) -> String {
233    text.replace('&', "&amp;")
234        .replace('<', "&lt;")
235        .replace('>', "&gt;")
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn escape_html_escapes_ampersand_first() {
244        assert_eq!(escape_html("<b>a & b</b>"), "&lt;b&gt;a &amp; b&lt;/b&gt;");
245    }
246
247    #[test]
248    fn escape_html_noop_on_plain_text() {
249        assert_eq!(escape_html("hello world"), "hello world");
250    }
251
252    #[test]
253    fn escape_html_handles_all_three_chars_together() {
254        assert_eq!(escape_html("a<b>c&d"), "a&lt;b&gt;c&amp;d");
255    }
256
257    #[test]
258    fn redact_strips_token_from_url_bearing_error_text() {
259        let token = "123456:ABC-secret";
260        let msg = format!(
261            "telegram rest: getUpdates http error: error sending request for url (https://api.telegram.org/bot{token}/getUpdates?timeout=30)"
262        );
263        let redacted = redact(token, msg);
264        assert!(!redacted.contains(token), "token leaked into: {redacted}");
265        assert!(redacted.contains("bot***"));
266    }
267}