agentmux_srv/messaging/telegram/
rest.rs1use std::time::Duration;
18
19use super::types::{EditMessageTextBody, Message, SendMessageBody, TelegramResponse};
20
21const TELEGRAM_API: &str = "https://api.telegram.org";
22
23const POLL_TIMEOUT_SECS: u64 = 30;
25const POLL_CLIENT_TIMEOUT_SECS: u64 = 35;
29const SEND_TIMEOUT_SECS: u64 = 10;
34
35#[derive(Debug, Clone)]
38pub struct TelegramApiError {
39 pub message: String,
40 pub retry_after: Option<u64>,
42 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
63fn redact(token: &str, s: String) -> String {
70 s.replace(token, "***")
71}
72
73pub 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
83pub 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
133pub 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
149pub 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
167pub 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
228pub fn escape_html(text: &str) -> String {
233 text.replace('&', "&")
234 .replace('<', "<")
235 .replace('>', ">")
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>"), "<b>a & b</b>");
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<b>c&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}