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}