Skip to main content

cratefield_adapter_telegram/
client.rs

1//! The outbound half: the [`TelegramBot`] port and its HTTP implementation
2//! [`HttpTelegramBot`], plus the local rate budgets.
3//!
4//! Telegram authenticates a bot by the URL path — `POST {base}/bot{token}/{method}`
5//! — so the token rides the one place most HTTP clients quote verbatim in an
6//! error. Every string that reaches a [`TelegramError`], a `Display`, a
7//! `Debug` or a tracing line goes through [`HttpTelegramBot::scrub`] first,
8//! and the client's own `Debug` prints a placeholder where the token would
9//! be. Nothing here formats the endpoint into a log line at all.
10
11use std::collections::HashMap;
12use std::fmt;
13use std::sync::Arc;
14use std::time::Duration;
15
16use async_trait::async_trait;
17use bytes::Bytes;
18use cratefield_core::{Clock, HttpClient, retry_after};
19use http::header::CONTENT_TYPE;
20use http::{HeaderMap, Method, Request, StatusCode};
21use serde::de::DeserializeOwned;
22use serde::{Deserialize, Serialize};
23use serde_json::Value as Json;
24
25/// Telegram's own API root; `with_base` overrides it for tests pointed at a
26/// fake.
27const DEFAULT_BASE: &str = "https://api.telegram.org";
28
29/// Telegram's cap on `callback_data`, in bytes. Longer data is refused at
30/// construction rather than spent against the rate budget on a call that
31/// would 400.
32const MAX_CALLBACK_DATA_BYTES: usize = 64;
33
34// ---------------------------------------------------------------------------
35// Outbound data types
36
37/// One button in an inline keyboard row.
38///
39/// Construct through [`InlineButton::callback`] and [`InlineButton::url`];
40/// the fields are public so tests (and a module with needs this crate did
41/// not anticipate) can build values directly — `send_message` re-validates
42/// what the constructors guarantee, so a hand-built button that Telegram
43/// would refuse is refused locally instead.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct InlineButton {
46    /// The text on the button.
47    pub text: String,
48    /// What pressing it does.
49    pub kind: ButtonKind,
50}
51
52/// What pressing an [`InlineButton`] does.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum ButtonKind {
55    /// Sends `data` back to the bot as a callback query — the string
56    /// `answer_callback` then acknowledges. Telegram caps the data at
57    /// 1..=64 bytes.
58    Callback(String),
59    /// Opens `url` in the client.
60    Url(String),
61}
62
63impl InlineButton {
64    /// A callback button. `data` is what the bot receives when it is
65    /// pressed — by convention a short machine-readable tag like
66    /// `"deploy:ack"`.
67    ///
68    /// # Errors
69    ///
70    /// [`TelegramError::Invalid`] when `data` is empty or longer than 64
71    /// bytes — Telegram's cap, refused here so it never costs an HTTP call.
72    pub fn callback(
73        text: impl Into<String>,
74        data: impl Into<String>,
75    ) -> Result<Self, TelegramError> {
76        let button = Self {
77            text: text.into(),
78            kind: ButtonKind::Callback(data.into()),
79        };
80        button.validate()?;
81        Ok(button)
82    }
83
84    /// A button that opens `url`. Telegram checks the URL at press time,
85    /// so there is nothing local to refuse here.
86    #[must_use]
87    pub fn url(text: impl Into<String>, url: impl Into<String>) -> Self {
88        Self {
89            text: text.into(),
90            kind: ButtonKind::Url(url.into()),
91        }
92    }
93
94    /// The constructor guarantee, re-checked by `send_message`/`edit_message`
95    /// against hand-built buttons.
96    fn validate(&self) -> Result<(), TelegramError> {
97        if let ButtonKind::Callback(data) = &self.kind {
98            let size = data.len();
99            if !(1..=MAX_CALLBACK_DATA_BYTES).contains(&size) {
100                return Err(TelegramError::Invalid(format!(
101                    "callback_data must be 1..={MAX_CALLBACK_DATA_BYTES} bytes, got {size}"
102                )));
103            }
104        }
105        Ok(())
106    }
107}
108
109/// A message to send: text, optionally an inline keyboard, optionally with
110/// the link preview suppressed.
111#[derive(Debug, Clone, PartialEq, Eq, Default)]
112pub struct OutgoingMessage {
113    /// Where the message goes: a user or chat id, negative for groups and
114    /// channels.
115    pub chat_id: i64,
116    /// The message text, 1..=4096 characters. Sent verbatim; formatting is
117    /// out of scope for this adapter.
118    pub text: String,
119    /// The inline keyboard, row by row.
120    pub buttons: Vec<Vec<InlineButton>>,
121    /// Whether the link preview is suppressed. Sent as
122    /// `link_preview_options.is_disabled` only when true.
123    pub disable_link_preview: bool,
124}
125
126impl OutgoingMessage {
127    /// A plain text message to `chat_id` — no buttons, preview enabled.
128    #[must_use]
129    pub fn new(chat_id: i64, text: impl Into<String>) -> Self {
130        Self {
131            chat_id,
132            text: text.into(),
133            buttons: Vec::new(),
134            disable_link_preview: false,
135        }
136    }
137
138    /// Appends one row of buttons to the inline keyboard.
139    #[must_use]
140    pub fn row(mut self, buttons: Vec<InlineButton>) -> Self {
141        self.buttons.push(buttons);
142        self
143    }
144}
145
146/// A message Telegram accepted: the chat it landed in and the id every
147/// later `edit_message`/`delete_message` call addresses.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct SentMessage {
150    /// The chat the message landed in, echoed back.
151    pub chat_id: i64,
152    /// The new message's id, positive.
153    pub message_id: i64,
154}
155
156// ---------------------------------------------------------------------------
157// The port
158
159/// The outbound chat port: the four Bot API calls a module needs. Object
160/// safe, so a module holds an `Arc<dyn TelegramBot>` and tests substitute
161/// [`fake::FakeTelegramBot`](crate::fake::FakeTelegramBot).
162#[async_trait]
163pub trait TelegramBot: Send + Sync {
164    /// Sends a text message, with any buttons, and reports where it landed.
165    ///
166    /// # Errors
167    ///
168    /// [`TelegramError::Invalid`] for a local refusal (empty text, a
169    /// `callback_data` outside 1..=64 bytes) or a 400 from Telegram,
170    /// [`TelegramError::RateLimited`] when a local budget is exhausted or
171    /// Telegram answered 429, [`TelegramError::Unauthorized`] for 401,
172    /// [`TelegramError::Forbidden`] for 403, [`TelegramError::Transient`]
173    /// for 5xx, transport failures and unparseable responses.
174    async fn send_message(&self, message: &OutgoingMessage) -> Result<SentMessage, TelegramError>;
175
176    /// Edits a message this bot previously sent, replacing the text and the
177    /// inline keyboard (an empty `buttons` removes the keyboard).
178    ///
179    /// # Errors
180    ///
181    /// The same mapping as [`TelegramBot::send_message`]; editing an unknown
182    /// or too-old message is Telegram's `Invalid`.
183    async fn edit_message(
184        &self,
185        chat_id: i64,
186        message_id: i64,
187        text: &str,
188        buttons: &[Vec<InlineButton>],
189    ) -> Result<(), TelegramError>;
190
191    /// Deletes a message this bot previously sent.
192    ///
193    /// # Errors
194    ///
195    /// The same mapping as [`TelegramBot::send_message`]; deleting an
196    /// unknown message is Telegram's `Invalid`.
197    async fn delete_message(&self, chat_id: i64, message_id: i64) -> Result<(), TelegramError>;
198
199    /// Acknowledges a callback query, optionally showing `text` as a toast
200    /// to the user who pressed the button.
201    ///
202    /// # Errors
203    ///
204    /// The same mapping as [`TelegramBot::send_message`].
205    async fn answer_callback(
206        &self,
207        callback_query_id: &str,
208        text: Option<&str>,
209    ) -> Result<(), TelegramError>;
210}
211
212// ---------------------------------------------------------------------------
213// Errors
214
215/// Why a call to Telegram failed.
216///
217/// `Debug` is derived, so no variant ever carries the bot token: every
218/// string built from an external message is scrubbed of the token before
219/// it becomes an error.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub enum TelegramError {
222    /// A local rate budget is exhausted, or Telegram answered 429. Wait at
223    /// least `retry_after` before trying the same call again.
224    RateLimited {
225        /// How long until the call may be retried.
226        retry_after: Duration,
227    },
228    /// 401: the bot token is wrong or revoked.
229    Unauthorized,
230    /// 403: the recipient blocked the bot, or the bot is no longer in the
231    /// chat. Carries Telegram's description.
232    Forbidden(String),
233    /// A 400/404 from Telegram, or a local refusal (bad `callback_data`,
234    /// empty text). Carries the description.
235    Invalid(String),
236    /// A 5xx, a transport failure, or a response this adapter cannot read.
237    /// Worth retrying later; carries what is known about the failure.
238    Transient(String),
239}
240
241impl TelegramError {
242    /// How long to wait before retrying, when the error said.
243    #[must_use]
244    pub fn retry_after(&self) -> Option<Duration> {
245        match self {
246            Self::RateLimited { retry_after } => Some(*retry_after),
247            _ => None,
248        }
249    }
250
251    /// Whether the call is worth retrying as-is later: rate limits clear
252    /// with time and transient failures often clear on their own. An
253    /// `Unauthorized`, `Forbidden` or `Invalid` needs a human or a
254    /// different input first.
255    #[must_use]
256    pub fn is_transient(&self) -> bool {
257        matches!(self, Self::RateLimited { .. } | Self::Transient(_))
258    }
259}
260
261impl fmt::Display for TelegramError {
262    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263        match self {
264            Self::RateLimited { retry_after } => {
265                write!(
266                    f,
267                    "telegram rate limited the call; retry after {retry_after:?}"
268                )
269            }
270            Self::Unauthorized => f.write_str("telegram rejected the bot token (401)"),
271            Self::Forbidden(description) => {
272                write!(f, "telegram refused the call (403): {description}")
273            }
274            Self::Invalid(description) => write!(f, "telegram rejected the call: {description}"),
275            Self::Transient(detail) => write!(f, "telegram call failed transiently: {detail}"),
276        }
277    }
278}
279
280impl std::error::Error for TelegramError {}
281
282// ---------------------------------------------------------------------------
283// Rate limits
284
285/// The local rate budgets `HttpTelegramBot` enforces before it sends, as
286/// token buckets fed by the injected `Clock`.
287///
288/// They approximate Telegram's documented budgets — 30 calls/second per bot,
289/// 1/second per private chat, 20/minute per group — but **per client
290/// instance**: in wasm terms, per isolate. A deployment running several
291/// isolates shares nothing here, and Telegram's budget is per bot; gate the
292/// fan-out through the runtime's `RateLimiter` port if the sum matters.
293///
294/// A zero disables that limit rather than refusing everything.
295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296pub struct RateLimits {
297    /// Whole-bot calls per second. Default 30.
298    pub global_per_sec: u32,
299    /// Calls per second per private chat (`chat_id >= 0`). Default 1.
300    pub private_chat_per_sec: u32,
301    /// Calls per minute per group or channel (`chat_id < 0`). Default 20.
302    pub group_per_min: u32,
303}
304
305impl Default for RateLimits {
306    /// Telegram's documented budgets: 30/s globally, 1/s per private chat,
307    /// 20/min per group.
308    fn default() -> Self {
309        Self {
310            global_per_sec: 30,
311            private_chat_per_sec: 1,
312            group_per_min: 20,
313        }
314    }
315}
316
317/// One token bucket, held in nano-tokens (`1e9` = one call) so a refill
318/// stays integer arithmetic and a `ManualClock` advance of exactly one
319/// period lands exactly on a whole token.
320struct Bucket {
321    /// The bucket's size, in nano-tokens.
322    capacity: u128,
323    /// Tokens on hand, in nano-tokens.
324    tokens: u128,
325    /// When the bucket was last touched, in nanoseconds since the epoch.
326    last_nanos: Option<u128>,
327}
328
329/// Nano-tokens per whole call.
330const NANOS_PER_TOKEN: u128 = 1_000_000_000;
331
332/// The per-chat maps grow one bucket per chat the bot ever messages, so
333/// past this many [`RateState::prune`] drops the ones nothing can reach
334/// through: buckets idle past [`BUCKET_IDLE_NANOS`] and the 429 deadlines
335/// whose moment has passed.
336const CHAT_BUCKETS_HIGH_WATER: usize = 1024;
337
338/// How long a per-chat bucket must go untouched before the sweep may drop
339/// it. A bucket's tokens refill lazily — inside `take`, from `last_nanos` —
340/// so what the map stores is what the bucket held when it was last used,
341/// not what it is worth now. But the slowest refill any configured rate can
342/// name is a minute (20 per minute), so a bucket idle for twice that has
343/// refilled to full regardless of configuration, and dropping it is
344/// indistinguishable from never having messaged that chat: it comes back
345/// full.
346const BUCKET_IDLE_NANOS: u128 = 120 * NANOS_PER_TOKEN;
347
348impl Bucket {
349    /// A full bucket of `capacity` calls.
350    fn new(capacity: u32) -> Self {
351        let capacity = u128::from(capacity) * NANOS_PER_TOKEN;
352        Self {
353            capacity,
354            tokens: capacity,
355            last_nanos: None,
356        }
357    }
358
359    /// Puts one token back. Only valid immediately after a **granted**
360    /// [`Bucket::take`] on the same bucket: a granted take leaves the bucket
361    /// a whole token below what it held after its refill, so
362    /// `tokens + NANOS_PER_TOKEN` cannot exceed `capacity` and the `min` is
363    /// a no-op guard. The bucket ends exactly as the granted take began.
364    fn untake(&mut self) {
365        self.tokens = self.capacity.min(self.tokens + NANOS_PER_TOKEN);
366    }
367
368    /// Grants one token (`None`), or reports how long the next token needs
369    /// (`Some`). `per_sec` is the refill rate as (units, denominator) calls
370    /// per second; the caller skips zero rates, so the division is safe.
371    fn take(&mut self, now_nanos: u128, per_sec: (u64, u64)) -> Option<Duration> {
372        if per_sec.0 == 0 {
373            return Some(Duration::MAX); // unreachable through the callers
374        }
375        let units = u128::from(per_sec.0);
376        let last = self.last_nanos.unwrap_or(now_nanos);
377        let elapsed = now_nanos.saturating_sub(last);
378        self.last_nanos = Some(now_nanos);
379        // Ceil the refill so a clock advanced by exactly one period never
380        // falls a nano-token short of a whole call.
381        let refill = ceil_div(elapsed.saturating_mul(units), u128::from(per_sec.1));
382        self.tokens = self.capacity.min(self.tokens.saturating_add(refill));
383        if self.tokens >= NANOS_PER_TOKEN {
384            self.tokens -= NANOS_PER_TOKEN;
385            return None;
386        }
387        // Nanoseconds until one more token: the missing fraction of a call,
388        // times the seconds one call takes.
389        let missing = NANOS_PER_TOKEN - self.tokens;
390        Some(duration_from_nanos(ceil_div(
391            missing.saturating_mul(u128::from(per_sec.1)),
392            units,
393        )))
394    }
395}
396
397/// `numerator / denominator`, rounded up. `denominator` is never zero at
398/// the call sites.
399fn ceil_div(numerator: u128, denominator: u128) -> u128 {
400    numerator / denominator + u128::from(!numerator.is_multiple_of(denominator))
401}
402
403/// Narrows nanoseconds into a `Duration`, saturating at `u64::MAX` — a
404/// wait that long is "not in this deployment's lifetime" anyway.
405fn duration_from_nanos(nanos: u128) -> Duration {
406    Duration::from_nanos(u64::try_from(nanos).unwrap_or(u64::MAX))
407}
408
409/// The mutable half of the budgets: the global bucket, one bucket per chat
410/// actually messaged, and the not-before deadlines a Telegram 429 set.
411struct RateState {
412    global: Bucket,
413    chats: HashMap<i64, Bucket>,
414    not_before: HashMap<i64, u128>,
415}
416
417impl RateState {
418    fn new(limits: &RateLimits) -> Self {
419        Self {
420            global: Bucket::new(limits.global_per_sec),
421            chats: HashMap::new(),
422            not_before: HashMap::new(),
423        }
424    }
425
426    /// Drops the chat state nothing can reach any more, once the per-chat
427    /// map has grown past [`CHAT_BUCKETS_HIGH_WATER`]: buckets idle past
428    /// [`BUCKET_IDLE_NANOS`] — long enough to have refilled to full, so a
429    /// dropped one would be recreated identically — and 429 deadlines whose
430    /// moment has passed. A recently used bucket keeps the refill credit it
431    /// has accrued, and a chat under a live deadline keeps its bucket,
432    /// whatever its age.
433    fn prune(&mut self, now: u128) {
434        if self.chats.len() < CHAT_BUCKETS_HIGH_WATER {
435            return;
436        }
437        self.chats.retain(|chat_id, bucket| {
438            if self
439                .not_before
440                .get(chat_id)
441                .is_some_and(|&until| now < until)
442            {
443                return true;
444            }
445            bucket
446                .last_nanos
447                .is_none_or(|last| now.saturating_sub(last) < BUCKET_IDLE_NANOS)
448        });
449        // Every deadline still in the future kept its chat's bucket above,
450        // so what survives here is exactly the expired garbage.
451        self.not_before.retain(|_chat_id, until| now < *until);
452    }
453}
454
455// Budget bookkeeping for the local rate limits — instance state a handle
456// shares across calls, not ambient request state; the scoped allow follows
457// the policy documented in the workspace clippy.toml (ADR 0007).
458#[allow(clippy::disallowed_types)]
459type Guarded<T> = std::sync::Mutex<T>;
460
461// ---------------------------------------------------------------------------
462// The HTTP bot
463
464/// [`TelegramBot`] over the Telegram Bot API: `POST {base}/bot{token}/{method}`
465/// with a JSON body, through the runtime's [`HttpClient`] port.
466///
467/// No `Debug` derive: the token is a struct field, and a derived `Debug`
468/// would print it wherever a log line met the adapter. The hand-written one
469/// prints a placeholder instead.
470pub struct HttpTelegramBot {
471    http: Arc<dyn HttpClient>,
472    /// Drives the rate buckets and parses a date-form `Retry-After`, the
473    /// same reason the other HTTP adapters hold one. A constructor argument
474    /// rather than a builder default so a deployment that forgets it fails
475    /// to compile instead of silently mis-budgeting.
476    clock: Arc<dyn Clock>,
477    /// The bot token, interpolated into the URL path. Never `Debug`d,
478    /// `Display`d or logged; scrubbed out of every error string.
479    token: String,
480    /// `None` — the default — posts to `https://api.telegram.org`; a value
481    /// overrides it for tests pointed at a fake.
482    base: Option<String>,
483    /// The configured budgets; `with_limits` swaps them (and resets the
484    /// accrued state).
485    limits: RateLimits,
486    /// The buckets and 429 deadlines behind a mutex — instance state, not
487    /// request state (see the `Guarded` note above).
488    budget: Guarded<RateState>,
489}
490
491impl fmt::Debug for HttpTelegramBot {
492    /// The bot token is a struct field, so it is written as a placeholder:
493    /// a log line that meets the adapter must never carry it.
494    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
495        f.debug_struct("HttpTelegramBot")
496            .field("base", &self.base)
497            .field("bot_token", &"[redacted]")
498            // The http and clock ports and the rate state are struct fields
499            // too; nothing about any of them belongs in a log line.
500            .finish_non_exhaustive()
501    }
502}
503
504impl HttpTelegramBot {
505    /// A bot pointed at Telegram under `bot_token`.
506    #[must_use]
507    pub fn new(
508        http: Arc<dyn HttpClient>,
509        clock: Arc<dyn Clock>,
510        bot_token: impl Into<String>,
511    ) -> Self {
512        let limits = RateLimits::default();
513        Self {
514            http,
515            clock,
516            token: bot_token.into(),
517            base: None,
518            limits,
519            budget: Guarded::new(RateState::new(&limits)),
520        }
521    }
522
523    /// Overrides the root every request is posted to — a fake in tests —
524    /// instead of `https://api.telegram.org`.
525    #[must_use]
526    pub fn with_base(mut self, base_url: impl Into<String>) -> Self {
527        self.base = Some(base_url.into());
528        self
529    }
530
531    /// Replaces the rate budgets with `limits`, discarding the tokens the
532    /// old budgets had accrued and any 429 deadlines. Chain after `new`.
533    #[must_use]
534    pub fn with_limits(mut self, limits: RateLimits) -> Self {
535        self.limits = limits;
536        self.budget = Guarded::new(RateState::new(&limits));
537        self
538    }
539
540    /// Registers `url` as the bot's webhook with `secret_token` in
541    /// [`webhook::SECRET_HEADER`](crate::webhook::SECRET_HEADER) and the
542    /// named update types delivered. An empty `url` deletes the webhook.
543    ///
544    /// Counted against the global budget only: there is no chat to scope a
545    /// per-chat bucket to, and a 429 here has nothing to remember one for.
546    ///
547    /// # Errors
548    ///
549    /// [`TelegramError::Invalid`] for a `url` that is neither `https` nor a
550    /// localhost `http` (the one exception Telegram allows), a secret
551    /// outside Telegram's alphabet, or a bot token that is not shaped like
552    /// one; otherwise the same mapping as [`TelegramBot::send_message`].
553    pub async fn set_webhook(
554        &self,
555        url: &str,
556        secret_token: &str,
557        allowed_updates: &[&str],
558    ) -> Result<(), TelegramError> {
559        if !url.is_empty() {
560            let localhost =
561                url.starts_with("http://localhost") || url.starts_with("http://127.0.0.1");
562            if !url.starts_with("https://") && !localhost {
563                return Err(TelegramError::Invalid(
564                    "webhook url must be https (http only for localhost)".to_owned(),
565                ));
566            }
567            if !crate::webhook::is_valid_secret(secret_token) {
568                return Err(TelegramError::Invalid(
569                    "webhook secret token must be 1-256 chars of A-Z a-z 0-9 '_' or '-'".to_owned(),
570                ));
571            }
572        }
573        let wire = SetWebhookWire {
574            url,
575            secret_token,
576            allowed_updates,
577        };
578        self.call("setWebhook", None, &wire).await.map(|_| ())
579    }
580
581    /// Grants the call its tokens, or refuses it before any HTTP request:
582    /// first a 429 deadline Telegram set for this chat, then the global
583    /// bucket, then the chat's own. `answer_callback` passes `None` and is
584    /// scoped by the global bucket alone. A refusal never spends a token:
585    /// one is taken only when the call goes on to send, so the global
586    /// token goes back when the chat's own bucket says no.
587    fn acquire(&self, chat: Option<i64>) -> Result<(), TelegramError> {
588        let now = self.now_nanos();
589        let mut state = self.budget.lock().expect("telegram rate-limit lock");
590        state.prune(now);
591        if let Some(chat_id) = chat
592            && let Some(&until) = state.not_before.get(&chat_id)
593            && now < until
594        {
595            return Err(TelegramError::RateLimited {
596                retry_after: duration_from_nanos(until - now),
597            });
598        }
599        if self.limits.global_per_sec > 0 {
600            let wait = state
601                .global
602                .take(now, (u64::from(self.limits.global_per_sec), 1));
603            if let Some(retry_after) = wait {
604                return Err(TelegramError::RateLimited { retry_after });
605            }
606        }
607        if let Some(chat_id) = chat {
608            let (rate, capacity) = if chat_id < 0 {
609                (
610                    (u64::from(self.limits.group_per_min), 60),
611                    self.limits.group_per_min,
612                )
613            } else {
614                (
615                    (u64::from(self.limits.private_chat_per_sec), 1),
616                    self.limits.private_chat_per_sec,
617                )
618            };
619            if capacity > 0 {
620                let bucket = state
621                    .chats
622                    .entry(chat_id)
623                    .or_insert_with(|| Bucket::new(capacity));
624                if let Some(retry_after) = bucket.take(now, rate) {
625                    // No request is about to be sent, so the global token
626                    // this call took goes back: a caller retrying a
627                    // chat-limited chat must not be able to drain the one
628                    // budget every other chat shares.
629                    if self.limits.global_per_sec > 0 {
630                        state.global.untake();
631                    }
632                    return Err(TelegramError::RateLimited { retry_after });
633                }
634            }
635        }
636        Ok(())
637    }
638
639    /// Remembers, after a Telegram 429, that `chat_id` may not be sent to
640    /// again until the delay Telegram named has passed — enforced locally
641    /// by [`HttpTelegramBot::acquire`], without another HTTP call.
642    fn note_not_before(&self, chat_id: i64, delay: Duration) {
643        let until = self.now_nanos().saturating_add(delay.as_nanos());
644        let mut state = self.budget.lock().expect("telegram rate-limit lock");
645        state
646            .not_before
647            .entry(chat_id)
648            .and_modify(|held| *held = (*held).max(until))
649            .or_insert(until);
650    }
651
652    /// Now, in nanoseconds since the epoch — the unit every bucket and
653    /// deadline counts in.
654    fn now_nanos(&self) -> u128 {
655        u128::try_from(self.clock.now().unix_timestamp_nanos()).unwrap_or(0)
656    }
657
658    /// Replaces the bot token in `text` with a placeholder. Every string
659    /// about to ride an error, a `Debug` or a log line goes through here:
660    /// the token rides the URL path, so even a transport error quoting the
661    /// request URI must not quote it verbatim.
662    fn scrub(&self, text: &str) -> String {
663        if self.token.is_empty() {
664            return text.to_owned();
665        }
666        text.replace(&self.token, "[redacted]")
667    }
668
669    /// Builds the request for one Bot API method. Never called with a
670    /// token-shaped-incorrect token, and the endpoint is never logged.
671    fn build_request(&self, method: &str, body: &Json) -> Result<Request<Bytes>, TelegramError> {
672        if !token_is_shape(&self.token) {
673            return Err(TelegramError::Invalid(
674                "bot token is not shaped like a Telegram token".to_owned(),
675            ));
676        }
677        let base = self
678            .base
679            .as_deref()
680            .unwrap_or(DEFAULT_BASE)
681            .trim_end_matches('/');
682        // Interpolated, never logged: the endpoint carries the token.
683        let token = &self.token;
684        let url = format!("{base}/bot{token}/{method}");
685        let payload = serde_json::to_vec(body)
686            .map_err(|error| TelegramError::Transient(error.to_string()))?;
687        Request::builder()
688            .method(Method::POST)
689            .uri(url)
690            .header(CONTENT_TYPE, "application/json")
691            .body(Bytes::from(payload))
692            .map_err(|error| {
693                // The builder quotes the URI it refused, so scrub: a bad
694                // base URL is operator configuration, not a leak.
695                TelegramError::Invalid(self.scrub(&error.to_string()))
696            })
697    }
698
699    /// One Bot API call: budget, send, envelope. `chat` scopes the
700    /// per-chat budget and is where a 429's deadline is remembered.
701    async fn call(
702        &self,
703        method: &str,
704        chat: Option<i64>,
705        body: &impl Serialize,
706    ) -> Result<Envelope, TelegramError> {
707        // Built before the budget is touched, so a call that cannot become
708        // a request — an unshaped token, a body that does not serialise —
709        // never spends a token on nothing.
710        let value = serde_json::to_value(body)
711            .map_err(|error| TelegramError::Transient(error.to_string()))?;
712        let request = self.build_request(method, &value)?;
713        self.acquire(chat)?;
714        // The endpoint carries the token, so it is deliberately absent from
715        // every log line; the method name says which call this was.
716        tracing::debug!(method, chat = ?chat, "telegram Bot API call");
717        let response = self
718            .http
719            .send(request)
720            .await
721            .map_err(|error| TelegramError::Transient(self.scrub(&error.to_string())))?;
722        let (parts, bytes) = response.into_parts();
723        let envelope = serde_json::from_slice::<Envelope>(&bytes).ok();
724        self.settle(parts.status, &parts.headers, envelope, chat)
725    }
726
727    /// Turns a response into an envelope or the mapped error. A body that
728    /// is not JSON at all is `Transient` whatever the status: never treat
729    /// an unreadable answer as success.
730    fn settle(
731        &self,
732        status: StatusCode,
733        headers: &HeaderMap,
734        envelope: Option<Envelope>,
735        chat: Option<i64>,
736    ) -> Result<Envelope, TelegramError> {
737        let Some(envelope) = envelope else {
738            return Err(TelegramError::Transient(
739                "telegram returned an unparseable response body".to_owned(),
740            ));
741        };
742        if status.is_success() && envelope.ok {
743            return Ok(envelope);
744        }
745        Err(self.api_error(status, headers, envelope, chat))
746    }
747
748    /// Maps a failure to [`TelegramError`]: 429 → `RateLimited` (and a
749    /// chat deadline remembered), 401 → `Unauthorized`, 403 → `Forbidden`,
750    /// other 4xx (Telegram's 400s and 404s) → `Invalid`, and 5xx — or any
751    /// status this mapping did not anticipate — → `Transient`.
752    fn api_error(
753        &self,
754        status: StatusCode,
755        headers: &HeaderMap,
756        envelope: Envelope,
757        chat: Option<i64>,
758    ) -> TelegramError {
759        let code = envelope.error_code.unwrap_or_else(|| status.as_u16());
760        let description = self.scrub(
761            envelope
762                .description
763                .as_deref()
764                .unwrap_or("telegram returned an error without a description"),
765        );
766        match code {
767            429 => {
768                let retry_after = envelope
769                    .parameters
770                    .and_then(|parameters| parameters.retry_after)
771                    .map(Duration::from_secs)
772                    .or_else(|| retry_after(headers, &*self.clock))
773                    .unwrap_or_else(|| Duration::from_secs(1));
774                if let Some(chat_id) = chat {
775                    self.note_not_before(chat_id, retry_after);
776                }
777                tracing::warn!(chat = ?chat, retry_after = ?retry_after, "telegram rate limited the bot");
778                TelegramError::RateLimited { retry_after }
779            }
780            401 => TelegramError::Unauthorized,
781            403 => TelegramError::Forbidden(description),
782            400..=499 => TelegramError::Invalid(description),
783            _ => TelegramError::Transient(description),
784        }
785    }
786}
787
788/// Whether `token` is shaped like a Telegram bot token (`<digits>:<chars>`).
789/// Checked before interpolation so a stray token can never bend the URL
790/// path it rides in.
791fn token_is_shape(token: &str) -> bool {
792    !token.is_empty()
793        && token
794            .bytes()
795            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'_' | b'-'))
796}
797
798/// Local refusals, decided before the budget is touched so a bad call never
799/// spends a token.
800fn validate_text(text: &str) -> Result<(), TelegramError> {
801    if text.trim().is_empty() {
802        return Err(TelegramError::Invalid("message text is empty".to_owned()));
803    }
804    Ok(())
805}
806
807fn validate_buttons(buttons: &[Vec<InlineButton>]) -> Result<(), TelegramError> {
808    for row in buttons {
809        for button in row {
810            button.validate()?;
811        }
812    }
813    Ok(())
814}
815
816fn validate_outgoing(message: &OutgoingMessage) -> Result<(), TelegramError> {
817    validate_text(&message.text)?;
818    validate_buttons(&message.buttons)
819}
820
821/// Reads `result` out of an ok envelope, or explains why it could not.
822fn deserialize_result<T: DeserializeOwned>(
823    bot: &HttpTelegramBot,
824    method: &str,
825    result: Option<Json>,
826) -> Result<T, TelegramError> {
827    let value =
828        result.ok_or_else(|| TelegramError::Transient(format!("{method} returned no result")))?;
829    serde_json::from_value(value).map_err(|error| {
830        TelegramError::Transient(bot.scrub(&format!(
831            "{method} returned a result this adapter cannot read: {error}"
832        )))
833    })
834}
835
836// ---------------------------------------------------------------------------
837// The wire
838
839#[derive(Serialize)]
840struct WireButton<'a> {
841    text: &'a str,
842    #[serde(skip_serializing_if = "Option::is_none")]
843    callback_data: Option<&'a str>,
844    #[serde(skip_serializing_if = "Option::is_none")]
845    url: Option<&'a str>,
846}
847
848#[derive(Serialize)]
849struct WireKeyboard<'a> {
850    inline_keyboard: Vec<Vec<WireButton<'a>>>,
851}
852
853fn keyboard(buttons: &[Vec<InlineButton>]) -> Option<WireKeyboard<'_>> {
854    if buttons.is_empty() {
855        return None;
856    }
857    Some(WireKeyboard {
858        inline_keyboard: buttons
859            .iter()
860            .map(|row| {
861                row.iter()
862                    .map(|button| match &button.kind {
863                        ButtonKind::Callback(data) => WireButton {
864                            text: &button.text,
865                            callback_data: Some(data),
866                            url: None,
867                        },
868                        ButtonKind::Url(url) => WireButton {
869                            text: &button.text,
870                            callback_data: None,
871                            url: Some(url),
872                        },
873                    })
874                    .collect()
875            })
876            .collect(),
877    })
878}
879
880#[derive(Serialize)]
881struct WireLinkPreview {
882    is_disabled: bool,
883}
884
885#[derive(Serialize)]
886struct SendMessageWire<'a> {
887    chat_id: i64,
888    text: &'a str,
889    #[serde(skip_serializing_if = "Option::is_none")]
890    reply_markup: Option<WireKeyboard<'a>>,
891    #[serde(skip_serializing_if = "Option::is_none")]
892    link_preview_options: Option<WireLinkPreview>,
893}
894
895#[derive(Serialize)]
896struct EditMessageWire<'a> {
897    chat_id: i64,
898    message_id: i64,
899    text: &'a str,
900    #[serde(skip_serializing_if = "Option::is_none")]
901    reply_markup: Option<WireKeyboard<'a>>,
902}
903
904#[derive(Serialize)]
905struct DeleteMessageWire {
906    chat_id: i64,
907    message_id: i64,
908}
909
910#[derive(Serialize)]
911struct AnswerCallbackWire<'a> {
912    callback_query_id: &'a str,
913    #[serde(skip_serializing_if = "Option::is_none")]
914    text: Option<&'a str>,
915}
916
917#[derive(Serialize)]
918struct SetWebhookWire<'a> {
919    url: &'a str,
920    secret_token: &'a str,
921    allowed_updates: &'a [&'a str],
922}
923
924/// Telegram's response envelope: `{"ok": true, "result": …}` on success,
925/// `{"ok": false, "error_code": …, "description": …,
926/// "parameters": {"retry_after": …}}` on failure. Unknown fields ignored.
927#[derive(Deserialize, Default)]
928struct Envelope {
929    /// Absent or false on a failure — the field every decision hangs on.
930    #[serde(default)]
931    ok: bool,
932    #[serde(default)]
933    result: Option<Json>,
934    #[serde(default)]
935    error_code: Option<u16>,
936    #[serde(default)]
937    description: Option<String>,
938    #[serde(default)]
939    parameters: Option<WireParameters>,
940}
941
942#[derive(Deserialize)]
943struct WireParameters {
944    #[serde(default)]
945    retry_after: Option<u64>,
946}
947
948/// The `result` of a successful `sendMessage`: the message's own id and
949/// chat.
950#[derive(Deserialize)]
951struct SentWire {
952    message_id: i64,
953    chat: SentChatWire,
954}
955
956#[derive(Deserialize)]
957struct SentChatWire {
958    id: i64,
959}
960
961#[async_trait]
962impl TelegramBot for HttpTelegramBot {
963    async fn send_message(&self, message: &OutgoingMessage) -> Result<SentMessage, TelegramError> {
964        validate_outgoing(message)?;
965        let wire = SendMessageWire {
966            chat_id: message.chat_id,
967            text: &message.text,
968            reply_markup: keyboard(&message.buttons),
969            link_preview_options: message
970                .disable_link_preview
971                .then_some(WireLinkPreview { is_disabled: true }),
972        };
973        let envelope = self
974            .call("sendMessage", Some(message.chat_id), &wire)
975            .await?;
976        let sent: SentWire = deserialize_result(self, "sendMessage", envelope.result)?;
977        Ok(SentMessage {
978            chat_id: sent.chat.id,
979            message_id: sent.message_id,
980        })
981    }
982
983    async fn edit_message(
984        &self,
985        chat_id: i64,
986        message_id: i64,
987        text: &str,
988        buttons: &[Vec<InlineButton>],
989    ) -> Result<(), TelegramError> {
990        validate_text(text)?;
991        validate_buttons(buttons)?;
992        let wire = EditMessageWire {
993            chat_id,
994            message_id,
995            text,
996            reply_markup: keyboard(buttons),
997        };
998        self.call("editMessageText", Some(chat_id), &wire)
999            .await
1000            .map(|_| ())
1001    }
1002
1003    async fn delete_message(&self, chat_id: i64, message_id: i64) -> Result<(), TelegramError> {
1004        let wire = DeleteMessageWire {
1005            chat_id,
1006            message_id,
1007        };
1008        self.call("deleteMessage", Some(chat_id), &wire)
1009            .await
1010            .map(|_| ())
1011    }
1012
1013    async fn answer_callback(
1014        &self,
1015        callback_query_id: &str,
1016        text: Option<&str>,
1017    ) -> Result<(), TelegramError> {
1018        let wire = AnswerCallbackWire {
1019            callback_query_id,
1020            text,
1021        };
1022        // Scoped by the global budget alone: a callback query names the
1023        // presser, not a chat, so there is nothing to budget per chat and
1024        // no deadline to remember for one.
1025        self.call("answerCallbackQuery", None, &wire)
1026            .await
1027            .map(|_| ())
1028    }
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033    //! The per-chat prune, against the state no public API exposes: a bot
1034    //! that has messaged thousands of chats must not keep a bucket (and a
1035    //! 429 deadline) for each forever, and the sweep must keep everything a
1036    //! refusal could still reach.
1037
1038    use super::*;
1039    use cratefield_core::HttpError;
1040    use time::OffsetDateTime;
1041
1042    /// Never called: these tests exercise `acquire` only, which sends
1043    /// nothing.
1044    struct NeverHttp;
1045
1046    #[async_trait::async_trait]
1047    impl HttpClient for NeverHttp {
1048        async fn send(&self, _request: Request<Bytes>) -> Result<http::Response<Bytes>, HttpError> {
1049            Err(HttpError::Transport("never sent".to_owned()))
1050        }
1051    }
1052
1053    /// A clock a test moves by hand, so a sweep's "how idle is idle" has a
1054    /// lever.
1055    struct TestClock(Guarded<OffsetDateTime>);
1056
1057    impl TestClock {
1058        fn started_at(instant: OffsetDateTime) -> Arc<Self> {
1059            Arc::new(Self(Guarded::new(instant)))
1060        }
1061
1062        fn advance(&self, seconds: i64) {
1063            *self.0.lock().expect("clock lock") += time::Duration::seconds(seconds);
1064        }
1065    }
1066
1067    impl Clock for TestClock {
1068        fn now(&self) -> OffsetDateTime {
1069            *self.0.lock().expect("clock lock")
1070        }
1071    }
1072
1073    fn epoch() -> OffsetDateTime {
1074        OffsetDateTime::from_unix_timestamp(1_767_225_600).expect("a representable instant")
1075    }
1076
1077    /// The global limit off — these tests watch the chat map, not the
1078    /// shared bucket — and the chat limit at its default.
1079    fn one_per_chat() -> RateLimits {
1080        RateLimits {
1081            global_per_sec: 0,
1082            private_chat_per_sec: 1,
1083            group_per_min: 20,
1084        }
1085    }
1086
1087    #[test]
1088    fn idle_buckets_are_pruned_once_the_map_grows_past_the_high_water() {
1089        let clock = TestClock::started_at(epoch());
1090        let bot = HttpTelegramBot::new(Arc::new(NeverHttp), clock.clone(), "test-bot-token")
1091            .with_limits(one_per_chat());
1092
1093        // A burst across more chats than the high water: every bucket was
1094        // touched just now, so the sweep that runs on each acquire has
1095        // nothing it may drop and the map keeps them all.
1096        let high_water = i64::try_from(CHAT_BUCKETS_HIGH_WATER).expect("fits a chat id");
1097        for chat in 0..high_water + 32 {
1098            bot.acquire(Some(chat)).expect("each chat starts full");
1099        }
1100        assert!(
1101            bot.budget.lock().expect("budget lock").chats.len() >= CHAT_BUCKETS_HIGH_WATER,
1102            "the burst itself is kept: every bucket was just used"
1103        );
1104
1105        // Two quiet minutes later every bucket has refilled to full — the
1106        // idle mark — and the next acquire's sweep drops them all but the
1107        // chat it is about to serve.
1108        clock.advance(121);
1109        bot.acquire(Some(-1)).expect("the new chat is served");
1110        let state = bot.budget.lock().expect("budget lock");
1111        assert_eq!(
1112            state.chats.len(),
1113            1,
1114            "every idle bucket from the burst was pruned"
1115        );
1116        assert!(state.not_before.is_empty());
1117    }
1118
1119    #[test]
1120    fn a_live_429_deadline_survives_the_sweep_and_an_expired_one_does_not() {
1121        let clock = TestClock::started_at(epoch());
1122        let bot = HttpTelegramBot::new(Arc::new(NeverHttp), clock.clone(), "test-bot-token")
1123            .with_limits(one_per_chat());
1124        // The production pairing: a 429's deadline is noted for a chat the
1125        // bot has just messaged, so both a bucket and a deadline exist.
1126        bot.acquire(Some(7)).expect("chat 7 starts full");
1127        bot.note_not_before(7, Duration::from_secs(10));
1128        bot.note_not_before(8, Duration::from_secs(1));
1129        // Fill the map past the high water with plain chat buckets.
1130        let high_water = i64::try_from(CHAT_BUCKETS_HIGH_WATER).expect("fits a chat id");
1131        for chat in 1000..1000 + high_water {
1132            bot.acquire(Some(chat)).expect("each chat starts full");
1133        }
1134
1135        // One second later: every burst bucket is still too recent to drop,
1136        // chat 7's deadline is live, chat 8's has passed — one sweep on the
1137        // next acquire settles all three.
1138        clock.advance(1);
1139        bot.acquire(Some(99_999)).expect("a fresh chat is served");
1140
1141        {
1142            let state = bot.budget.lock().expect("budget lock");
1143            assert!(
1144                state.chats.contains_key(&7) && state.not_before.contains_key(&7),
1145                "the chat under a live deadline is kept"
1146            );
1147            assert!(
1148                !state.not_before.contains_key(&8),
1149                "the expired deadline is garbage, collected"
1150            );
1151        }
1152        let error = bot.acquire(Some(7)).expect_err("the deadline holds");
1153        assert_eq!(
1154            error.retry_after(),
1155            Some(Duration::from_secs(9)),
1156            "the surviving deadline still governs, at its remaining time"
1157        );
1158    }
1159}