Skip to main content

bamboo_server/connect/platforms/
telegram.rs

1//! Telegram long-poll platform adapter (issue #452 phase 1 MVP; buttons +
2//! `editMessageText` + `callback_query` added in issue #458 phase 2): no
3//! public IP, no webhook, no WS — just `getUpdates` over plain HTTPS.
4
5use std::collections::HashMap;
6use std::sync::atomic::{AtomicI64, Ordering};
7use std::sync::OnceLock;
8use std::time::Duration;
9
10use tokio::sync::{mpsc, Mutex as AsyncMutex};
11
12use super::super::platform::{
13    Button, CallbackQuery, Capabilities, Inbound, InboundMessage, MessageRef, OutboundMessage,
14    Platform, PlatformError, PlatformResult, ReplyCtx,
15};
16use super::super::render::{chunk_message, MAX_MESSAGE_CHARS};
17
18const DEFAULT_BASE_URL: &str = "https://api.telegram.org";
19/// Telegram's own long-poll timeout — the server holds the `getUpdates`
20/// connection open for up to this many seconds waiting for a new update.
21const LONG_POLL_TIMEOUT_SECS: u64 = 30;
22/// Backoff between `getUpdates` retries after a transport/parse failure.
23const RETRY_BACKOFF: Duration = Duration::from_secs(5);
24/// Default outgoing rate limit: 1 message/second per chat (telegram-safe;
25/// Telegram's own documented soft limit is ~1 msg/sec per chat).
26const DEFAULT_RATE_LIMIT_INTERVAL: Duration = Duration::from_secs(1);
27
28/// One shared `reqwest::Client`. Reuses the workspace's pinned (native-tls)
29/// `reqwest` — never construct a second client/connector for this adapter
30/// (mirrors `notify_sinks::ntfy::http_client`).
31fn http_client() -> &'static reqwest::Client {
32    static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
33    CLIENT.get_or_init(reqwest::Client::new)
34}
35
36#[derive(Debug, serde::Deserialize)]
37struct TelegramResponse<T> {
38    ok: bool,
39    #[serde(default)]
40    result: Option<T>,
41    #[serde(default)]
42    description: Option<String>,
43}
44
45#[derive(Debug, Clone, serde::Deserialize)]
46struct TelegramUpdate {
47    update_id: i64,
48    #[serde(default)]
49    message: Option<TelegramMessage>,
50    #[serde(default)]
51    callback_query: Option<TelegramCallbackQuery>,
52}
53
54/// An inline-button press (issue #458). `message` is the message the button
55/// was attached to — its `chat` gives us the chat to route the resolution
56/// against; a callback with no `message` (can happen for very old/inline
57/// messages) is dropped by [`TelegramPlatform::to_inbound_callback`], same
58/// treatment as a text update this MVP doesn't handle.
59#[derive(Debug, Clone, serde::Deserialize)]
60struct TelegramCallbackQuery {
61    id: String,
62    #[serde(default)]
63    from: Option<TelegramUser>,
64    #[serde(default)]
65    message: Option<TelegramMessage>,
66    #[serde(default)]
67    data: Option<String>,
68}
69
70#[derive(Debug, Clone, Default, serde::Deserialize)]
71struct TelegramMessage {
72    #[serde(default)]
73    message_id: i64,
74    /// Unix timestamp (seconds) — Telegram's own message send time.
75    #[serde(default)]
76    date: i64,
77    #[serde(default)]
78    chat: TelegramChat,
79    #[serde(default)]
80    from: Option<TelegramUser>,
81    #[serde(default)]
82    text: Option<String>,
83}
84
85#[derive(Debug, Clone, Default, serde::Deserialize)]
86struct TelegramChat {
87    #[serde(default)]
88    id: i64,
89}
90
91#[derive(Debug, Clone, serde::Deserialize)]
92struct TelegramUser {
93    id: i64,
94}
95
96/// Per-chat outgoing token bucket: blocks (never drops) until at least
97/// `min_interval` has elapsed since the last send to that chat. Reserves the
98/// next allowed slot atomically under a short-held lock, then sleeps
99/// OUTSIDE the lock — so a chat waiting on its slot never blocks a send to a
100/// different chat.
101struct RateLimiter {
102    next_allowed: AsyncMutex<HashMap<String, tokio::time::Instant>>,
103    min_interval: Duration,
104}
105
106impl RateLimiter {
107    fn new(min_interval: Duration) -> Self {
108        Self {
109            next_allowed: AsyncMutex::new(HashMap::new()),
110            min_interval,
111        }
112    }
113
114    async fn wait(&self, key: &str) {
115        let now = tokio::time::Instant::now();
116        let scheduled = {
117            let mut guard = self.next_allowed.lock().await;
118            let earliest = guard.get(key).copied().unwrap_or(now);
119            let scheduled = earliest.max(now);
120            guard.insert(key.to_string(), scheduled + self.min_interval);
121            // Bound growth (issue #454 follow-up): without this, the map
122            // gains one entry per distinct chat id for the life of the
123            // process, even after a chat goes permanently idle. Sweep out
124            // every entry whose reserved slot has ALREADY elapsed — safe
125            // because a swept key's next `wait()` falls back to `now` via
126            // `unwrap_or(now)` above, i.e. exactly the value we're
127            // discarding, so this never changes scheduling behavior. The
128            // entry this call just inserted is always `> now` (it's
129            // `scheduled + min_interval` with `scheduled >= now`), so it
130            // always survives its own sweep.
131            guard.retain(|_, next_allowed| *next_allowed > now);
132            scheduled
133        };
134        if scheduled > now {
135            tokio::time::sleep(scheduled - now).await;
136        }
137    }
138
139    #[cfg(test)]
140    async fn tracked_chat_count(&self) -> usize {
141        self.next_allowed.lock().await.len()
142    }
143}
144
145pub struct TelegramPlatform {
146    token: String,
147    base_url: String,
148    offset: AtomicI64,
149    rate_limiter: RateLimiter,
150}
151
152impl TelegramPlatform {
153    /// Production constructor: official Telegram API base URL, 1 msg/s
154    /// per-chat rate limit.
155    pub fn new(token: String) -> Self {
156        Self::with_options(
157            token,
158            DEFAULT_BASE_URL.to_string(),
159            DEFAULT_RATE_LIMIT_INTERVAL,
160        )
161    }
162
163    /// Test/advanced constructor: override the base URL (a local HTTP stub)
164    /// and/or the rate-limit interval (kept tiny in tests so a
165    /// rate-limit-blocks assertion doesn't need a real second-plus sleep).
166    pub fn with_options(token: String, base_url: String, rate_limit_interval: Duration) -> Self {
167        Self {
168            token,
169            base_url,
170            offset: AtomicI64::new(0),
171            rate_limiter: RateLimiter::new(rate_limit_interval),
172        }
173    }
174
175    fn api_url(&self, method: &str) -> String {
176        format!(
177            "{}/bot{}/{method}",
178            self.base_url.trim_end_matches('/'),
179            self.token
180        )
181    }
182
183    /// Format a `reqwest::Error` for logs/errors WITHOUT the bot token.
184    ///
185    /// Telegram puts the token in the URL path (`/bot<token>/<method>`), and
186    /// `reqwest::Error`'s `Display` includes the request URL when one is
187    /// attached ("error sending request for url (…)"), so a naive
188    /// `format!("{error}")` on any transient network failure would print the
189    /// live token into ordinary server logs. Strip the URL from the error
190    /// (`without_url`) and, belt-and-braces, redact any literal token
191    /// occurrence in whatever text remains (e.g. proxy errors that embed the
192    /// target URL themselves).
193    fn sanitize_error(&self, error: reqwest::Error) -> String {
194        let text = error.without_url().to_string();
195        if self.token.is_empty() {
196            return text;
197        }
198        text.replace(&self.token, "[REDACTED]")
199    }
200
201    async fn get_updates(
202        &self,
203        offset: i64,
204        timeout_secs: u64,
205    ) -> PlatformResult<Vec<TelegramUpdate>> {
206        // Issue #458: request `callback_query` updates alongside `message` —
207        // without `allowed_updates`, a bot that has never called `setWebhook`
208        // already receives both by default, but being explicit keeps this
209        // adapter correct even if that default ever changes upstream.
210        let allowed_updates =
211            serde_json::to_string(&["message", "callback_query"]).unwrap_or_default();
212        let response = http_client()
213            .get(self.api_url("getUpdates"))
214            .query(&[
215                ("offset", offset.to_string()),
216                ("timeout", timeout_secs.to_string()),
217                ("allowed_updates", allowed_updates),
218            ])
219            // Generous margin over Telegram's own long-poll timeout so the
220            // HTTP client doesn't time out the connection out from under a
221            // legitimately-long-held poll.
222            .timeout(Duration::from_secs(timeout_secs + 15))
223            .send()
224            .await
225            .map_err(|error| {
226                PlatformError::other(format!(
227                    "getUpdates request failed: {}",
228                    self.sanitize_error(error)
229                ))
230            })?;
231
232        let parsed: TelegramResponse<Vec<TelegramUpdate>> =
233            response.json().await.map_err(|error| {
234                PlatformError::other(format!(
235                    "getUpdates response parse failed: {}",
236                    self.sanitize_error(error)
237                ))
238            })?;
239
240        if !parsed.ok {
241            return Err(PlatformError::other(
242                parsed
243                    .description
244                    .unwrap_or_else(|| "getUpdates returned ok=false".to_string()),
245            ));
246        }
247        Ok(parsed.result.unwrap_or_default())
248    }
249
250    /// Converts a raw update into a bridge-facing [`InboundMessage`].
251    /// Returns `None` for updates this MVP doesn't handle (no `message`, no
252    /// text, no sender) — the caller still advances the offset for these so
253    /// Telegram never re-delivers them.
254    fn to_inbound_message(update: &TelegramUpdate) -> Option<InboundMessage> {
255        let message = update.message.as_ref()?;
256        let text = message.text.clone()?;
257        let from = message.from.as_ref()?;
258        let sent_at = chrono::DateTime::<chrono::Utc>::from_timestamp(message.date, 0)
259            .unwrap_or_else(chrono::Utc::now);
260        Some(InboundMessage {
261            platform: "telegram".to_string(),
262            chat_id: message.chat.id.to_string(),
263            user_id: from.id.to_string(),
264            message_id: update.update_id.to_string(),
265            sent_at,
266            text,
267            reply_ctx: ReplyCtx(serde_json::json!({ "chat_id": message.chat.id })),
268        })
269    }
270
271    /// Converts a raw update's `callback_query` into a bridge-facing
272    /// [`Inbound::Callback`]. Returns `None` when there's no
273    /// `callback_query`, or it's missing `from`/`message`/`data` (nothing to
274    /// route a resolution against) — the caller still advances the offset
275    /// for these.
276    fn to_inbound_callback(update: &TelegramUpdate) -> Option<Inbound> {
277        let callback_query = update.callback_query.as_ref()?;
278        let from = callback_query.from.as_ref()?;
279        let message = callback_query.message.as_ref()?;
280        let data = callback_query.data.clone()?;
281        Some(Inbound::Callback(CallbackQuery {
282            platform: "telegram".to_string(),
283            chat_id: message.chat.id.to_string(),
284            user_id: from.id.to_string(),
285            callback_query_id: callback_query.id.clone(),
286            data,
287            reply_ctx: ReplyCtx(serde_json::json!({ "chat_id": message.chat.id })),
288        }))
289    }
290
291    /// One `getUpdates(offset, timeout_secs)` cycle: fetches, advances
292    /// `self.offset` past EVERY returned update (so Telegram never
293    /// re-delivers one this MVP skips), and returns the subset that convert
294    /// to an [`Inbound`] event (a text message or a button-press callback).
295    /// Used both for `start()`'s drain-on-start pass (`timeout_secs = 0`,
296    /// result discarded) and its main long-poll loop — factored out so tests
297    /// can drive a single cycle deterministically against a local HTTP stub
298    /// without looping forever.
299    async fn poll_once(&self, timeout_secs: u64) -> PlatformResult<Vec<Inbound>> {
300        let offset = self.offset.load(Ordering::SeqCst);
301        let updates = self.get_updates(offset, timeout_secs).await?;
302
303        let mut events = Vec::with_capacity(updates.len());
304        for update in &updates {
305            // Always advance past this update_id, whether or not we forward
306            // it — an un-forwarded update (no text, no sender, …) would
307            // otherwise be redelivered by Telegram forever.
308            self.offset.store(update.update_id + 1, Ordering::SeqCst);
309            if let Some(callback) = Self::to_inbound_callback(update) {
310                events.push(callback);
311            } else if let Some(message) = Self::to_inbound_message(update) {
312                events.push(Inbound::Message(message));
313            }
314        }
315        Ok(events)
316    }
317
318    fn extract_chat_id(ctx: &ReplyCtx) -> PlatformResult<String> {
319        Self::extract_chat_id_value(&ctx.0)
320    }
321
322    fn extract_chat_id_value(value: &serde_json::Value) -> PlatformResult<String> {
323        value
324            .get("chat_id")
325            .and_then(|v| {
326                v.as_i64()
327                    .map(|n| n.to_string())
328                    .or_else(|| v.as_str().map(|s| s.to_string()))
329            })
330            .ok_or_else(|| PlatformError::other("reply_ctx is missing chat_id"))
331    }
332
333    /// Telegram's `reply_markup` inline-keyboard shape: `{"inline_keyboard":
334    /// [[{"text": ..., "callback_data": ...}], ...]}`, JSON-encoded as a
335    /// single form field (issue #458).
336    fn build_reply_markup(buttons: &[Vec<Button>]) -> String {
337        let rows: Vec<Vec<serde_json::Value>> = buttons
338            .iter()
339            .map(|row| {
340                row.iter()
341                    .map(|button| {
342                        serde_json::json!({
343                            "text": button.label,
344                            "callback_data": button.callback_data,
345                        })
346                    })
347                    .collect()
348            })
349            .collect();
350        serde_json::json!({ "inline_keyboard": rows }).to_string()
351    }
352}
353
354#[async_trait::async_trait]
355impl Platform for TelegramPlatform {
356    fn name(&self) -> &str {
357        "telegram"
358    }
359
360    fn capabilities(&self) -> Capabilities {
361        Capabilities {
362            buttons: true,
363            edit_message: true,
364            images: false,
365            files: false,
366        }
367    }
368
369    async fn start(&self, inbound: mpsc::Sender<Inbound>) -> PlatformResult<()> {
370        // Drain-on-start: fetch (and silently discard) any backlog that
371        // accumulated while the bot was offline, so a restart never replays
372        // a burst of stale prompts. A non-blocking (`timeout=0`) call.
373        match self.poll_once(0).await {
374            Ok(drained) if !drained.is_empty() => {
375                tracing::info!(
376                    "connect: telegram drained {} stale update(s) on start",
377                    drained.len()
378                );
379            }
380            Ok(_) => {}
381            Err(error) => {
382                tracing::warn!("connect: telegram drain-on-start failed (continuing): {error}");
383            }
384        }
385
386        loop {
387            match self.poll_once(LONG_POLL_TIMEOUT_SECS).await {
388                Ok(events) => {
389                    for event in events {
390                        if inbound.send(event).await.is_err() {
391                            // Receiver dropped: the manager is shutting down.
392                            return Ok(());
393                        }
394                    }
395                }
396                Err(error) => {
397                    tracing::warn!("connect: telegram getUpdates failed, retrying: {error}");
398                    tokio::time::sleep(RETRY_BACKOFF).await;
399                }
400            }
401        }
402    }
403
404    async fn reply(&self, ctx: &ReplyCtx, msg: OutboundMessage) -> PlatformResult<MessageRef> {
405        let chat_id = Self::extract_chat_id(ctx)?;
406        let mut last_message_id = None;
407        let reply_markup = msg.buttons.as_deref().map(Self::build_reply_markup);
408        let chunks = chunk_message(&msg.text, MAX_MESSAGE_CHARS);
409        let chunk_count = chunks.len();
410
411        for (index, chunk) in chunks.into_iter().enumerate() {
412            self.rate_limiter.wait(&chat_id).await;
413
414            let mut form: Vec<(&str, String)> = vec![("chat_id", chat_id.clone()), ("text", chunk)];
415            // Attach the keyboard only to the LAST chunk — buttons make sense
416            // on one message, not on earlier text-overflow spillover.
417            if index + 1 == chunk_count {
418                if let Some(markup) = &reply_markup {
419                    form.push(("reply_markup", markup.clone()));
420                }
421            }
422
423            let response = http_client()
424                .post(self.api_url("sendMessage"))
425                .form(&form)
426                .send()
427                .await
428                .map_err(|error| {
429                    PlatformError::other(format!(
430                        "sendMessage request failed: {}",
431                        self.sanitize_error(error)
432                    ))
433                })?;
434
435            let parsed: TelegramResponse<TelegramMessage> =
436                response.json().await.map_err(|error| {
437                    PlatformError::other(format!(
438                        "sendMessage response parse failed: {}",
439                        self.sanitize_error(error)
440                    ))
441                })?;
442
443            if !parsed.ok {
444                return Err(PlatformError::other(
445                    parsed
446                        .description
447                        .unwrap_or_else(|| "sendMessage returned ok=false".to_string()),
448                ));
449            }
450            last_message_id = parsed.result.map(|m| m.message_id);
451        }
452
453        Ok(MessageRef(serde_json::json!({
454            "chat_id": chat_id,
455            "message_id": last_message_id,
456        })))
457    }
458
459    async fn edit(&self, msg_ref: &MessageRef, new: OutboundMessage) -> PlatformResult<()> {
460        let chat_id = Self::extract_chat_id_value(&msg_ref.0)?;
461        let message_id = msg_ref
462            .0
463            .get("message_id")
464            .and_then(|v| v.as_i64())
465            .ok_or_else(|| PlatformError::other("message_ref is missing message_id"))?;
466
467        self.rate_limiter.wait(&chat_id).await;
468
469        let mut form: Vec<(&str, String)> = vec![
470            ("chat_id", chat_id),
471            ("message_id", message_id.to_string()),
472            ("text", new.text),
473        ];
474        if let Some(buttons) = &new.buttons {
475            form.push(("reply_markup", Self::build_reply_markup(buttons)));
476        }
477
478        let response = http_client()
479            .post(self.api_url("editMessageText"))
480            .form(&form)
481            .send()
482            .await
483            .map_err(|error| {
484                PlatformError::other(format!(
485                    "editMessageText request failed: {}",
486                    self.sanitize_error(error)
487                ))
488            })?;
489
490        let parsed: TelegramResponse<TelegramMessage> = response.json().await.map_err(|error| {
491            PlatformError::other(format!(
492                "editMessageText response parse failed: {}",
493                self.sanitize_error(error)
494            ))
495        })?;
496
497        if !parsed.ok {
498            // The caller (`connect::render::StreamingRenderer`) degrades to a
499            // fresh `reply()` on any edit error — a stale/too-old message, an
500            // unchanged-content 400, or anything else Telegram rejects.
501            return Err(PlatformError::other(parsed.description.unwrap_or_else(
502                || "editMessageText returned ok=false".to_string(),
503            )));
504        }
505        Ok(())
506    }
507
508    async fn answer_callback(
509        &self,
510        callback_query_id: &str,
511        text: Option<&str>,
512    ) -> PlatformResult<()> {
513        // Deliberately NOT rate-limited: `answerCallbackQuery` isn't a chat
514        // message (it dismisses the client's loading spinner) and Telegram
515        // expects it promptly — sharing the per-chat send throttle here would
516        // only risk the ack timing out.
517        let mut form: Vec<(&str, String)> =
518            vec![("callback_query_id", callback_query_id.to_string())];
519        if let Some(text) = text {
520            form.push(("text", text.to_string()));
521        }
522
523        let response = http_client()
524            .post(self.api_url("answerCallbackQuery"))
525            .form(&form)
526            .send()
527            .await
528            .map_err(|error| {
529                PlatformError::other(format!(
530                    "answerCallbackQuery request failed: {}",
531                    self.sanitize_error(error)
532                ))
533            })?;
534
535        let parsed: TelegramResponse<bool> = response.json().await.map_err(|error| {
536            PlatformError::other(format!(
537                "answerCallbackQuery response parse failed: {}",
538                self.sanitize_error(error)
539            ))
540        })?;
541
542        if !parsed.ok {
543            return Err(PlatformError::other(parsed.description.unwrap_or_else(
544                || "answerCallbackQuery returned ok=false".to_string(),
545            )));
546        }
547        Ok(())
548    }
549
550    async fn stop(&self) -> PlatformResult<()> {
551        Ok(())
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558
559    fn platform_with_stub(base_url: String) -> TelegramPlatform {
560        TelegramPlatform::with_options(
561            "test-token".to_string(),
562            base_url,
563            Duration::from_millis(50),
564        )
565    }
566
567    async fn wait_for_requests(
568        server: &wiremock::MockServer,
569        expected: usize,
570    ) -> Vec<wiremock::Request> {
571        for _ in 0..100 {
572            if let Some(requests) = server.received_requests().await {
573                if requests.len() >= expected {
574                    return requests;
575                }
576            }
577            tokio::time::sleep(Duration::from_millis(10)).await;
578        }
579        server.received_requests().await.unwrap_or_default()
580    }
581
582    #[tokio::test]
583    async fn poll_once_advances_offset_past_every_returned_update() {
584        let server = wiremock::MockServer::start().await;
585        wiremock::Mock::given(wiremock::matchers::method("GET"))
586            .and(wiremock::matchers::path("/bottest-token/getUpdates"))
587            .respond_with(
588                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
589                    "ok": true,
590                    "result": [
591                        {
592                            "update_id": 100,
593                            "message": {
594                                "message_id": 1,
595                                "date": 1_700_000_000,
596                                "chat": { "id": 42 },
597                                "from": { "id": 7 },
598                                "text": "hello"
599                            }
600                        },
601                        {
602                            "update_id": 101
603                            // no "message" -- must still advance the offset past it.
604                        }
605                    ]
606                })),
607            )
608            .mount(&server)
609            .await;
610
611        let platform = platform_with_stub(server.uri());
612        let events = platform.poll_once(30).await.expect("poll_once succeeds");
613
614        assert_eq!(events.len(), 1);
615        match &events[0] {
616            Inbound::Message(message) => {
617                assert_eq!(message.chat_id, "42");
618                assert_eq!(message.user_id, "7");
619                assert_eq!(message.message_id, "100");
620                assert_eq!(message.text, "hello");
621            }
622            Inbound::Callback(_) => panic!("expected a message event"),
623        }
624        assert_eq!(platform.offset.load(Ordering::SeqCst), 102);
625    }
626
627    #[tokio::test]
628    async fn poll_once_next_call_requests_the_advanced_offset() {
629        let server = wiremock::MockServer::start().await;
630        wiremock::Mock::given(wiremock::matchers::method("GET"))
631            .and(wiremock::matchers::path("/bottest-token/getUpdates"))
632            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
633                "ok": true,
634                "result": [
635                    {
636                        "update_id": 5,
637                        "message": {
638                            "message_id": 1, "date": 1, "chat": {"id": 1}, "from": {"id": 1}, "text": "hi"
639                        }
640                    }
641                ]
642            })))
643            .mount(&server)
644            .await;
645
646        let platform = platform_with_stub(server.uri());
647        platform.poll_once(30).await.unwrap();
648        assert_eq!(platform.offset.load(Ordering::SeqCst), 6);
649
650        let requests = wait_for_requests(&server, 1).await;
651        let query: HashMap<String, String> = requests[0]
652            .url
653            .query_pairs()
654            .map(|(k, v)| (k.to_string(), v.to_string()))
655            .collect();
656        assert_eq!(query.get("offset"), Some(&"0".to_string()));
657
658        // A second poll must request the ADVANCED offset.
659        let _ = platform.poll_once(30).await;
660        let requests = wait_for_requests(&server, 2).await;
661        let query: HashMap<String, String> = requests[1]
662            .url
663            .query_pairs()
664            .map(|(k, v)| (k.to_string(), v.to_string()))
665            .collect();
666        assert_eq!(query.get("offset"), Some(&"6".to_string()));
667    }
668
669    #[tokio::test]
670    async fn reply_chunks_long_text_into_multiple_send_message_calls() {
671        let server = wiremock::MockServer::start().await;
672        wiremock::Mock::given(wiremock::matchers::method("POST"))
673            .and(wiremock::matchers::path("/bottest-token/sendMessage"))
674            .respond_with(
675                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
676                    "ok": true,
677                    "result": { "message_id": 1, "date": 1, "chat": { "id": 1 } }
678                })),
679            )
680            .mount(&server)
681            .await;
682
683        let platform = platform_with_stub(server.uri());
684        let ctx = ReplyCtx(serde_json::json!({ "chat_id": 1 }));
685        let long_text = "a".repeat(9000); // -> 3 chunks at 4096
686
687        platform
688            .reply(&ctx, OutboundMessage::text(long_text))
689            .await
690            .expect("reply succeeds");
691
692        let requests = wait_for_requests(&server, 3).await;
693        assert_eq!(requests.len(), 3, "expected exactly 3 sendMessage calls");
694    }
695
696    #[tokio::test]
697    async fn reply_short_text_sends_exactly_one_message() {
698        let server = wiremock::MockServer::start().await;
699        wiremock::Mock::given(wiremock::matchers::method("POST"))
700            .and(wiremock::matchers::path("/bottest-token/sendMessage"))
701            .respond_with(
702                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
703                    "ok": true,
704                    "result": { "message_id": 1, "date": 1, "chat": { "id": 1 } }
705                })),
706            )
707            .mount(&server)
708            .await;
709
710        let platform = platform_with_stub(server.uri());
711        let ctx = ReplyCtx(serde_json::json!({ "chat_id": 1 }));
712
713        platform
714            .reply(&ctx, OutboundMessage::text("hello"))
715            .await
716            .expect("reply succeeds");
717
718        let requests = wait_for_requests(&server, 1).await;
719        assert_eq!(requests.len(), 1);
720    }
721
722    #[tokio::test]
723    async fn reply_rate_limits_consecutive_sends_to_the_same_chat() {
724        let server = wiremock::MockServer::start().await;
725        wiremock::Mock::given(wiremock::matchers::method("POST"))
726            .and(wiremock::matchers::path("/bottest-token/sendMessage"))
727            .respond_with(
728                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
729                    "ok": true,
730                    "result": { "message_id": 1, "date": 1, "chat": { "id": 1 } }
731                })),
732            )
733            .mount(&server)
734            .await;
735
736        // 50ms rate-limit interval (see `platform_with_stub`) keeps the test fast.
737        let platform = platform_with_stub(server.uri());
738        let ctx = ReplyCtx(serde_json::json!({ "chat_id": 1 }));
739
740        let start = tokio::time::Instant::now();
741        platform
742            .reply(&ctx, OutboundMessage::text("first"))
743            .await
744            .unwrap();
745        platform
746            .reply(&ctx, OutboundMessage::text("second"))
747            .await
748            .unwrap();
749        let elapsed = start.elapsed();
750
751        assert!(
752            elapsed >= Duration::from_millis(50),
753            "second send to the same chat must block for the rate-limit interval, elapsed={elapsed:?}"
754        );
755    }
756
757    #[tokio::test]
758    async fn reply_does_not_rate_limit_different_chats() {
759        let server = wiremock::MockServer::start().await;
760        wiremock::Mock::given(wiremock::matchers::method("POST"))
761            .and(wiremock::matchers::path("/bottest-token/sendMessage"))
762            .respond_with(
763                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
764                    "ok": true,
765                    "result": { "message_id": 1, "date": 1, "chat": { "id": 1 } }
766                })),
767            )
768            .mount(&server)
769            .await;
770
771        let platform = platform_with_stub(server.uri());
772        let ctx_a = ReplyCtx(serde_json::json!({ "chat_id": 1 }));
773        let ctx_b = ReplyCtx(serde_json::json!({ "chat_id": 2 }));
774
775        let start = tokio::time::Instant::now();
776        platform
777            .reply(&ctx_a, OutboundMessage::text("first"))
778            .await
779            .unwrap();
780        platform
781            .reply(&ctx_b, OutboundMessage::text("second"))
782            .await
783            .unwrap();
784        let elapsed = start.elapsed();
785
786        assert!(
787            elapsed < Duration::from_millis(50),
788            "sends to DIFFERENT chats must not share a rate-limit slot, elapsed={elapsed:?}"
789        );
790    }
791
792    /// Issue #454 follow-up: `RateLimiter::next_allowed` must not grow one
793    /// entry per distinct chat for the life of the process — a chat's entry
794    /// is swept once its reserved slot has elapsed, rather than lingering
795    /// forever.
796    #[tokio::test]
797    async fn rate_limiter_sweeps_stale_entries_instead_of_growing_unbounded() {
798        let limiter = RateLimiter::new(Duration::from_millis(20));
799
800        limiter.wait("chat-1").await;
801        assert_eq!(limiter.tracked_chat_count().await, 1);
802
803        // Let chat-1's reserved slot fully elapse before any other chat is
804        // seen.
805        tokio::time::sleep(Duration::from_millis(40)).await;
806
807        // A wait for a DIFFERENT chat must sweep chat-1's now-stale entry
808        // out rather than accumulate it forever.
809        limiter.wait("chat-2").await;
810        assert_eq!(
811            limiter.tracked_chat_count().await,
812            1,
813            "stale chat-1 entry must have been swept when chat-2 was scheduled"
814        );
815
816        // Simulate many distinct, one-shot chats spaced far enough apart
817        // that each previous entry is stale by the time the next arrives —
818        // the tracked count must stay bounded to the currently-relevant
819        // entry/entries, not grow to the number of chats ever seen.
820        for i in 0..50 {
821            tokio::time::sleep(Duration::from_millis(25)).await;
822            limiter.wait(&format!("burst-chat-{i}")).await;
823            assert!(
824                limiter.tracked_chat_count().await <= 2,
825                "map grew unbounded after {i} one-shot chats"
826            );
827        }
828    }
829
830    #[tokio::test]
831    async fn capabilities_advertise_buttons_and_edit_message() {
832        let platform = platform_with_stub("http://localhost:0".to_string());
833        let caps = platform.capabilities();
834        assert!(caps.buttons);
835        assert!(caps.edit_message);
836        assert!(!caps.images);
837        assert!(!caps.files);
838    }
839
840    #[tokio::test]
841    async fn get_updates_requests_message_and_callback_query_updates() {
842        let server = wiremock::MockServer::start().await;
843        wiremock::Mock::given(wiremock::matchers::method("GET"))
844            .and(wiremock::matchers::path("/bottest-token/getUpdates"))
845            .respond_with(
846                wiremock::ResponseTemplate::new(200)
847                    .set_body_json(serde_json::json!({ "ok": true, "result": [] })),
848            )
849            .mount(&server)
850            .await;
851
852        let platform = platform_with_stub(server.uri());
853        platform.poll_once(30).await.unwrap();
854
855        let requests = wait_for_requests(&server, 1).await;
856        let query: HashMap<String, String> = requests[0]
857            .url
858            .query_pairs()
859            .map(|(k, v)| (k.to_string(), v.to_string()))
860            .collect();
861        let allowed: Vec<String> = serde_json::from_str(
862            query
863                .get("allowed_updates")
864                .expect("allowed_updates present"),
865        )
866        .unwrap();
867        assert_eq!(
868            allowed,
869            vec!["message".to_string(), "callback_query".to_string()]
870        );
871    }
872
873    #[tokio::test]
874    async fn poll_once_converts_callback_query_updates_to_inbound_callbacks() {
875        let server = wiremock::MockServer::start().await;
876        wiremock::Mock::given(wiremock::matchers::method("GET"))
877            .and(wiremock::matchers::path("/bottest-token/getUpdates"))
878            .respond_with(
879                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
880                    "ok": true,
881                    "result": [
882                        {
883                            "update_id": 200,
884                            "callback_query": {
885                                "id": "cbq-1",
886                                "from": { "id": 7 },
887                                "message": {
888                                    "message_id": 1,
889                                    "date": 1_700_000_000,
890                                    "chat": { "id": 42 }
891                                },
892                                "data": "nonce123:1"
893                            }
894                        }
895                    ]
896                })),
897            )
898            .mount(&server)
899            .await;
900
901        let platform = platform_with_stub(server.uri());
902        let events = platform.poll_once(30).await.expect("poll_once succeeds");
903
904        assert_eq!(events.len(), 1);
905        match &events[0] {
906            Inbound::Callback(callback) => {
907                assert_eq!(callback.platform, "telegram");
908                assert_eq!(callback.chat_id, "42");
909                assert_eq!(callback.user_id, "7");
910                assert_eq!(callback.callback_query_id, "cbq-1");
911                assert_eq!(callback.data, "nonce123:1");
912            }
913            Inbound::Message(_) => panic!("expected a callback event"),
914        }
915        assert_eq!(platform.offset.load(Ordering::SeqCst), 201);
916    }
917
918    #[tokio::test]
919    async fn reply_with_buttons_sends_inline_keyboard_reply_markup() {
920        let server = wiremock::MockServer::start().await;
921        wiremock::Mock::given(wiremock::matchers::method("POST"))
922            .and(wiremock::matchers::path("/bottest-token/sendMessage"))
923            .respond_with(
924                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
925                    "ok": true,
926                    "result": { "message_id": 9, "date": 1, "chat": { "id": 1 } }
927                })),
928            )
929            .mount(&server)
930            .await;
931
932        let platform = platform_with_stub(server.uri());
933        let ctx = ReplyCtx(serde_json::json!({ "chat_id": 1 }));
934        let outbound = OutboundMessage::text("Approve?").with_buttons(vec![
935            vec![Button::new("Approve", "n1:0")],
936            vec![Button::new("Deny", "n1:1")],
937        ]);
938
939        let msg_ref = platform
940            .reply(&ctx, outbound)
941            .await
942            .expect("reply succeeds");
943        assert_eq!(
944            msg_ref.0.get("message_id").and_then(|v| v.as_i64()),
945            Some(9)
946        );
947
948        let requests = wait_for_requests(&server, 1).await;
949        let body = String::from_utf8(requests[0].body.clone()).unwrap();
950        let form: HashMap<String, String> = url::form_urlencoded::parse(body.as_bytes())
951            .into_owned()
952            .collect();
953        let markup: serde_json::Value =
954            serde_json::from_str(form.get("reply_markup").expect("reply_markup present")).unwrap();
955        assert_eq!(
956            markup["inline_keyboard"][0][0]["callback_data"],
957            serde_json::json!("n1:0")
958        );
959        assert_eq!(
960            markup["inline_keyboard"][1][0]["text"],
961            serde_json::json!("Deny")
962        );
963    }
964
965    #[tokio::test]
966    async fn edit_sends_edit_message_text_with_the_stored_message_id() {
967        let server = wiremock::MockServer::start().await;
968        wiremock::Mock::given(wiremock::matchers::method("POST"))
969            .and(wiremock::matchers::path("/bottest-token/editMessageText"))
970            .respond_with(
971                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
972                    "ok": true,
973                    "result": { "message_id": 9, "date": 1, "chat": { "id": 1 } }
974                })),
975            )
976            .mount(&server)
977            .await;
978
979        let platform = platform_with_stub(server.uri());
980        let msg_ref = MessageRef(serde_json::json!({ "chat_id": 1, "message_id": 9 }));
981
982        platform
983            .edit(&msg_ref, OutboundMessage::text("updated"))
984            .await
985            .expect("edit succeeds");
986
987        let requests = wait_for_requests(&server, 1).await;
988        let body = String::from_utf8(requests[0].body.clone()).unwrap();
989        let form: HashMap<String, String> = url::form_urlencoded::parse(body.as_bytes())
990            .into_owned()
991            .collect();
992        assert_eq!(form.get("message_id").map(String::as_str), Some("9"));
993        assert_eq!(form.get("text").map(String::as_str), Some("updated"));
994    }
995
996    #[tokio::test]
997    async fn edit_returns_an_error_on_a_400_response_instead_of_panicking() {
998        let server = wiremock::MockServer::start().await;
999        wiremock::Mock::given(wiremock::matchers::method("POST"))
1000            .and(wiremock::matchers::path("/bottest-token/editMessageText"))
1001            .respond_with(
1002                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1003                    "ok": false,
1004                    "description": "Bad Request: message is not modified"
1005                })),
1006            )
1007            .mount(&server)
1008            .await;
1009
1010        let platform = platform_with_stub(server.uri());
1011        let msg_ref = MessageRef(serde_json::json!({ "chat_id": 1, "message_id": 9 }));
1012
1013        let result = platform
1014            .edit(&msg_ref, OutboundMessage::text("same text"))
1015            .await;
1016        // The caller (`connect::render`) is responsible for degrading to a
1017        // fresh send on this Err — verified in `render.rs`'s
1018        // `streaming_mode_edit_failure_degrades_to_a_fresh_send` test.
1019        assert!(result.is_err());
1020    }
1021
1022    #[tokio::test]
1023    async fn answer_callback_posts_callback_query_id_and_optional_text() {
1024        let server = wiremock::MockServer::start().await;
1025        wiremock::Mock::given(wiremock::matchers::method("POST"))
1026            .and(wiremock::matchers::path(
1027                "/bottest-token/answerCallbackQuery",
1028            ))
1029            .respond_with(
1030                wiremock::ResponseTemplate::new(200)
1031                    .set_body_json(serde_json::json!({ "ok": true, "result": true })),
1032            )
1033            .mount(&server)
1034            .await;
1035
1036        let platform = platform_with_stub(server.uri());
1037        platform
1038            .answer_callback("cbq-1", Some("This action has expired."))
1039            .await
1040            .expect("answer_callback succeeds");
1041
1042        let requests = wait_for_requests(&server, 1).await;
1043        let body = String::from_utf8(requests[0].body.clone()).unwrap();
1044        let form: HashMap<String, String> = url::form_urlencoded::parse(body.as_bytes())
1045            .into_owned()
1046            .collect();
1047        assert_eq!(
1048            form.get("callback_query_id").map(String::as_str),
1049            Some("cbq-1")
1050        );
1051        assert_eq!(
1052            form.get("text").map(String::as_str),
1053            Some("This action has expired.")
1054        );
1055    }
1056
1057    #[tokio::test]
1058    async fn answer_callback_round_trips_without_text() {
1059        let server = wiremock::MockServer::start().await;
1060        wiremock::Mock::given(wiremock::matchers::method("POST"))
1061            .and(wiremock::matchers::path(
1062                "/bottest-token/answerCallbackQuery",
1063            ))
1064            .respond_with(
1065                wiremock::ResponseTemplate::new(200)
1066                    .set_body_json(serde_json::json!({ "ok": true, "result": true })),
1067            )
1068            .mount(&server)
1069            .await;
1070
1071        let platform = platform_with_stub(server.uri());
1072        platform
1073            .answer_callback("cbq-2", None)
1074            .await
1075            .expect("answer_callback succeeds");
1076
1077        let requests = wait_for_requests(&server, 1).await;
1078        let body = String::from_utf8(requests[0].body.clone()).unwrap();
1079        let form: HashMap<String, String> = url::form_urlencoded::parse(body.as_bytes())
1080            .into_owned()
1081            .collect();
1082        assert!(!form.contains_key("text"));
1083    }
1084
1085    /// The bot token lives in the request URL path; `reqwest::Error`'s
1086    /// `Display` would print it on any transport failure. Force a real
1087    /// connection error (port 1 is unroutable/refused) and assert the token
1088    /// never appears in the surfaced error text.
1089    #[tokio::test]
1090    async fn transport_errors_never_leak_the_bot_token() {
1091        let token = "123456:SECRET-TOKEN-MUST-NOT-LEAK";
1092        let platform = TelegramPlatform::with_options(
1093            token.to_string(),
1094            "http://127.0.0.1:1".to_string(),
1095            Duration::from_millis(1),
1096        );
1097        let err = platform
1098            .get_updates(0, 0)
1099            .await
1100            .expect_err("port 1 must refuse the connection");
1101        let text = format!("{err}");
1102        assert!(
1103            !text.contains(token) && !text.contains("SECRET-TOKEN"),
1104            "token leaked into error text: {text}"
1105        );
1106        assert!(
1107            text.contains("getUpdates request failed"),
1108            "unexpected error shape: {text}"
1109        );
1110    }
1111}