Skip to main content

zeph_channels/
telegram_api_ext.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Raw HTTP client for Telegram Bot API 10.0 methods not yet exposed by teloxide.
5//!
6//! [`TelegramApiClient`] wraps `reqwest` and provides typed async methods for
7//! Bot API 10.0 features: Guest Mode query answering, managed-bot access
8//! settings, and reaction moderation.  It is embedded in [`TelegramChannel`]
9//! and accessible via [`TelegramChannel::api_ext`].
10//!
11//! Once teloxide gains native support for these methods, this module can be
12//! removed and call sites updated to use the teloxide API directly (tracked in
13//! issue #3732).
14//!
15//! [`TelegramChannel`]: crate::telegram::TelegramChannel
16//! [`TelegramChannel::api_ext`]: crate::telegram::TelegramChannel::api_ext
17
18use std::time::Duration;
19
20use serde::{Deserialize, Serialize};
21
22/// Per-request timeout applied to every `reqwest::Client` created by
23/// [`TelegramApiClient`]. Matches the project's general policy for external
24/// HTTP calls that are not long-polling or streaming.
25const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
26
27/// Response payload from the `answerGuestQuery` Bot API method.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
29pub struct SentGuestMessage {
30    /// Telegram message identifier of the sent reply.
31    pub message_id: i64,
32    /// Identifier of the chat the message was sent to.
33    pub chat_id: i64,
34}
35
36/// Access settings for a managed bot in Guest Mode (Bot API 10.0).
37///
38/// Controls which message sources the managed bot is allowed to receive.
39/// Both fields default to `false`; set `allow_bot_messages = true` to enable
40/// bot-to-bot communication.
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42pub struct BotAccessSettings {
43    /// Whether the managed bot may receive messages from users.
44    pub allow_user_messages: bool,
45    /// Whether the managed bot may receive messages from other bots.
46    pub allow_bot_messages: bool,
47}
48
49/// Minimal user info extracted from a guest message update.
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51pub struct GuestUser {
52    /// Telegram user identifier.
53    pub id: i64,
54    /// Whether this user is a bot.
55    pub is_bot: bool,
56    /// Telegram username, without `@`.
57    pub username: Option<String>,
58    /// Display name.
59    pub first_name: String,
60}
61
62/// Minimal chat info extracted from a guest message update.
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
64pub struct GuestChat {
65    /// Telegram chat identifier.
66    pub id: i64,
67    /// Chat type (e.g. `"group"`, `"supergroup"`, `"channel"`).
68    #[serde(rename = "type")]
69    pub chat_type: String,
70}
71
72/// A guest message update from Bot API 10.0.
73///
74/// Received when a user @mentions the bot in a chat where the bot is not a
75/// member. The `guest_query_id` is required for responding via
76/// [`TelegramApiClient::answer_guest_query`].
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
78pub struct GuestMessage {
79    /// Opaque identifier for this guest query, used in `answerGuestQuery`.
80    pub guest_query_id: String,
81    /// The user who @mentioned the bot.
82    pub guest_bot_caller_user: GuestUser,
83    /// The chat where the mention occurred.
84    pub guest_bot_caller_chat: GuestChat,
85    /// Text content of the message, if any.
86    pub text: Option<String>,
87}
88
89/// Chat member status as returned by `getChatMember`.
90///
91/// Only the status variants relevant to admin checks are represented. Any
92/// unrecognised status string is captured by the `Other` variant.
93#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
94#[serde(rename_all = "snake_case")]
95#[non_exhaustive]
96pub enum ChatMemberStatus {
97    /// The user is the chat creator.
98    Creator,
99    /// The user is an administrator.
100    Administrator,
101    /// The user is a regular member.
102    Member,
103    /// The user is restricted.
104    Restricted,
105    /// The user has left the chat.
106    Left,
107    /// The user was kicked or banned.
108    Kicked,
109    /// Unknown or future status value.
110    #[serde(other)]
111    Other,
112}
113
114/// Minimal chat member info returned by `getChatMember`.
115#[derive(Debug, Clone, Deserialize)]
116pub struct ChatMember {
117    /// Membership status.
118    pub status: ChatMemberStatus,
119    /// The user this record describes.
120    pub user: GuestUser,
121}
122
123impl ChatMember {
124    /// Whether this member has admin-level privileges (creator or administrator).
125    #[must_use]
126    pub fn is_admin(&self) -> bool {
127        matches!(
128            self.status,
129            ChatMemberStatus::Creator | ChatMemberStatus::Administrator
130        )
131    }
132}
133
134/// Standard Telegram API JSON response envelope.
135#[derive(Deserialize)]
136struct TelegramResponse<T> {
137    ok: bool,
138    result: Option<T>,
139    description: Option<String>,
140}
141
142/// Errors returned by [`TelegramApiClient`] methods.
143#[derive(Debug, thiserror::Error)]
144#[non_exhaustive]
145pub enum TelegramApiError {
146    /// HTTP transport or status error.
147    #[error("HTTP error: {0}")]
148    Http(#[from] reqwest::Error),
149    /// Telegram API returned `ok: false`.
150    #[error("Telegram API error: {0}")]
151    Api(String),
152}
153
154/// Raw HTTP client for Telegram Bot API 10.0 methods not covered by teloxide.
155///
156/// The bot token is embedded in the base URL and is never written to logs — the
157/// [`Debug`] implementation redacts it.
158///
159/// # Examples
160///
161/// ```no_run
162/// use zeph_channels::telegram_api_ext::TelegramApiClient;
163///
164/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
165/// let client = TelegramApiClient::new("123456:ABC-DEF…");
166/// let result = client.answer_guest_query("query_id", "Hello!", None).await?;
167/// println!("sent message_id={}", result.message_id);
168/// # Ok(())
169/// # }
170/// ```
171#[derive(Clone)]
172pub struct TelegramApiClient {
173    client: reqwest::Client,
174    /// `https://api.telegram.org/bot<TOKEN>` — includes the token, never log.
175    base_url: String,
176}
177
178impl std::fmt::Debug for TelegramApiClient {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        f.debug_struct("TelegramApiClient")
181            .field("base_url", &"[REDACTED]")
182            .finish_non_exhaustive()
183    }
184}
185
186impl TelegramApiClient {
187    /// Create a new client for the given bot `token`.
188    ///
189    /// The base URL is set to `https://api.telegram.org/bot<TOKEN>` so that each
190    /// `post()` call appends only the method name (e.g., `/answerGuestQuery`).
191    ///
192    /// Creates an independent `reqwest::Client` with its own connection pool and
193    /// a [`REQUEST_TIMEOUT`] per-request timeout. To share a connection pool with
194    /// an existing client, use [`TelegramApiClient::with_client`].
195    ///
196    /// # Panics
197    ///
198    /// Panics if the TLS backend cannot be initialised (i.e. `reqwest::ClientBuilder::build`
199    /// returns an error). This does not occur in practice when the crate is compiled with a
200    /// supported TLS backend.
201    #[must_use]
202    pub fn new(token: impl Into<String>) -> Self {
203        let token = token.into();
204        Self {
205            client: reqwest::Client::builder()
206                .timeout(REQUEST_TIMEOUT)
207                .build()
208                .expect("reqwest TLS backend unavailable"),
209            base_url: format!("https://api.telegram.org/bot{token}"),
210        }
211    }
212
213    /// Create a client that reuses an existing `reqwest::Client`.
214    ///
215    /// This allows sharing a connection pool with another HTTP client — for
216    /// example, the `reqwest::Client` backing teloxide's `Bot` — to avoid
217    /// opening duplicate TCP connections to `api.telegram.org`.
218    ///
219    /// The base URL is set to `https://api.telegram.org/bot<token>` using the
220    /// supplied `token`.
221    ///
222    /// # Examples
223    ///
224    /// ```no_run
225    /// use zeph_channels::telegram_api_ext::TelegramApiClient;
226    ///
227    /// let shared = reqwest::Client::new();
228    /// let client = TelegramApiClient::with_client(shared, "123456:ABC-DEF…");
229    /// ```
230    #[must_use]
231    pub fn with_client(client: reqwest::Client, token: &str) -> Self {
232        Self {
233            client,
234            base_url: format!("https://api.telegram.org/bot{token}"),
235        }
236    }
237
238    /// Fetch the bot's own user information via `getMe`.
239    ///
240    /// Returns the bot's Telegram user ID. This is the value to pass as
241    /// `bot_user_id` when constructing a `TelegramModerationBackend` for
242    /// pre-flight admin checks.
243    ///
244    /// # Errors
245    ///
246    /// Returns [`TelegramApiError`] on HTTP failure or when `ok: false`.
247    ///
248    /// # Examples
249    ///
250    /// ```no_run
251    /// use zeph_channels::telegram_api_ext::TelegramApiClient;
252    ///
253    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
254    /// let client = TelegramApiClient::new("TOKEN");
255    /// let me = client.get_me().await?;
256    /// println!("bot user id: {}", me.id);
257    /// # Ok(())
258    /// # }
259    /// ```
260    #[cfg_attr(
261        feature = "profiling",
262        tracing::instrument(name = "channels.telegram.get_me", skip_all)
263    )]
264    pub async fn get_me(&self) -> Result<GuestUser, TelegramApiError> {
265        self.post("getMe", &serde_json::json!({})).await
266    }
267
268    /// Create a client with a fully-qualified custom base URL.
269    ///
270    /// The `base_url` is stored as-is and each method name is appended with `/`.
271    /// The bot token is **not** automatically embedded — the caller is responsible
272    /// for including the full path prefix required by the target server.
273    ///
274    /// For the official Telegram Bot API protocol the expected format is:
275    /// `https://api.telegram.org/bot<TOKEN>` (same as what [`new`] builds).
276    /// For a local [Telegram Bot API server](https://core.telegram.org/bots/api#using-a-local-bot-api-server)
277    /// the format is typically `http://localhost:8081/bot<TOKEN>`.
278    ///
279    /// This method is primarily intended for testing (point at a wiremock server)
280    /// or for deployments that proxy through a local Bot API server.
281    ///
282    /// # Panics
283    ///
284    /// Panics if the TLS backend cannot be initialised. This does not occur in practice
285    /// when the crate is compiled with a supported TLS backend.
286    ///
287    /// [`new`]: TelegramApiClient::new
288    #[must_use]
289    pub fn with_base_url(base_url: impl Into<String>) -> Self {
290        Self {
291            client: reqwest::Client::builder()
292                .timeout(REQUEST_TIMEOUT)
293                .build()
294                .expect("reqwest TLS backend unavailable"),
295            base_url: base_url.into(),
296        }
297    }
298
299    /// POST `body` to `{base_url}/{method}` and deserialize the result.
300    ///
301    /// Calls `.error_for_status()` before JSON parsing so that non-2xx HTTP
302    /// responses (e.g. 429 rate-limit, 502 gateway error) surface as
303    /// [`TelegramApiError::Http`] rather than serde deserialization errors.
304    /// URLs (which contain the bot token) are stripped from any `reqwest::Error`
305    /// before propagation to prevent token leakage into logs.
306    #[tracing::instrument(skip(self, body), fields(method = method))]
307    async fn post<T: serde::de::DeserializeOwned>(
308        &self,
309        method: &str,
310        body: &impl Serialize,
311    ) -> Result<T, TelegramApiError> {
312        let url = format!("{}/{method}", self.base_url);
313        let resp: TelegramResponse<T> = self
314            .client
315            .post(&url)
316            .json(body)
317            .send()
318            .await
319            .map_err(|e| TelegramApiError::Http(e.without_url()))?
320            .error_for_status()
321            .map_err(|e| TelegramApiError::Http(e.without_url()))?
322            .json()
323            .await
324            .map_err(|e| TelegramApiError::Http(e.without_url()))?;
325
326        if resp.ok {
327            resp.result
328                .ok_or_else(|| TelegramApiError::Api("ok=true but no result".into()))
329        } else {
330            Err(TelegramApiError::Api(
331                resp.description.unwrap_or_else(|| "unknown error".into()),
332            ))
333        }
334    }
335
336    /// Answer a Guest Mode query on behalf of a managed bot.
337    ///
338    /// # Arguments
339    ///
340    /// * `query_id` — identifier of the guest query to answer.
341    /// * `text` — reply text.
342    /// * `parse_mode` — optional parse mode (`"HTML"`, `"MarkdownV2"`, etc.).
343    ///
344    /// # Errors
345    ///
346    /// Returns [`TelegramApiError`] on HTTP failure or when `ok: false`.
347    ///
348    /// # Examples
349    ///
350    /// ```no_run
351    /// use zeph_channels::telegram_api_ext::TelegramApiClient;
352    ///
353    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
354    /// let client = TelegramApiClient::new("TOKEN");
355    /// let sent = client.answer_guest_query("qid_123", "Hello!", Some("HTML")).await?;
356    /// println!("message_id={}", sent.message_id);
357    /// # Ok(())
358    /// # }
359    /// ```
360    #[cfg_attr(
361        feature = "profiling",
362        tracing::instrument(name = "channels.telegram.answer_guest_query", skip_all)
363    )]
364    pub async fn answer_guest_query(
365        &self,
366        query_id: &str,
367        text: &str,
368        parse_mode: Option<&str>,
369    ) -> Result<SentGuestMessage, TelegramApiError> {
370        #[derive(Serialize)]
371        struct Req<'a> {
372            guest_query_id: &'a str,
373            text: &'a str,
374            #[serde(skip_serializing_if = "Option::is_none")]
375            parse_mode: Option<&'a str>,
376        }
377        self.post(
378            "answerGuestQuery",
379            &Req {
380                guest_query_id: query_id,
381                text,
382                parse_mode,
383            },
384        )
385        .await
386    }
387
388    /// Retrieve access settings for a managed bot.
389    ///
390    /// # Errors
391    ///
392    /// Returns [`TelegramApiError`] on HTTP failure or when `ok: false`.
393    ///
394    /// # Examples
395    ///
396    /// ```no_run
397    /// use zeph_channels::telegram_api_ext::TelegramApiClient;
398    ///
399    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
400    /// let client = TelegramApiClient::new("TOKEN");
401    /// let settings = client.get_managed_bot_access_settings().await?;
402    /// println!("bot_messages={}", settings.allow_bot_messages);
403    /// # Ok(())
404    /// # }
405    /// ```
406    #[cfg_attr(
407        feature = "profiling",
408        tracing::instrument(name = "channels.telegram.get_managed_bot_access_settings", skip_all)
409    )]
410    pub async fn get_managed_bot_access_settings(
411        &self,
412    ) -> Result<BotAccessSettings, TelegramApiError> {
413        self.post("getManagedBotAccessSettings", &serde_json::json!({}))
414            .await
415    }
416
417    /// Update access settings for a managed bot.
418    ///
419    /// Returns `true` when the settings were applied successfully.
420    ///
421    /// # Errors
422    ///
423    /// Returns [`TelegramApiError`] on HTTP failure or when `ok: false`.
424    ///
425    /// # Examples
426    ///
427    /// ```no_run
428    /// use zeph_channels::telegram_api_ext::{BotAccessSettings, TelegramApiClient};
429    ///
430    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
431    /// let client = TelegramApiClient::new("TOKEN");
432    /// let ok = client.set_managed_bot_access_settings(&BotAccessSettings {
433    ///     allow_user_messages: true,
434    ///     allow_bot_messages: true,
435    /// }).await?;
436    /// assert!(ok);
437    /// # Ok(())
438    /// # }
439    /// ```
440    #[cfg_attr(
441        feature = "profiling",
442        tracing::instrument(name = "channels.telegram.set_managed_bot_access_settings", skip_all)
443    )]
444    pub async fn set_managed_bot_access_settings(
445        &self,
446        settings: &BotAccessSettings,
447    ) -> Result<bool, TelegramApiError> {
448        self.post("setManagedBotAccessSettings", settings).await
449    }
450
451    /// Delete a specific reaction left by `user_id` on a message.
452    ///
453    /// Returns `true` on success.
454    ///
455    /// # Arguments
456    ///
457    /// * `chat_id` — identifier of the chat containing the message.
458    /// * `message_id` — identifier of the message.
459    /// * `user_id` — identifier of the user whose reaction to remove.
460    /// * `reaction` — emoji or custom reaction string to remove.
461    ///
462    /// # Errors
463    ///
464    /// Returns [`TelegramApiError`] on HTTP failure or when `ok: false`.
465    ///
466    /// # Examples
467    ///
468    /// ```no_run
469    /// use zeph_channels::telegram_api_ext::TelegramApiClient;
470    ///
471    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
472    /// let client = TelegramApiClient::new("TOKEN");
473    /// let ok = client.delete_message_reaction(123, 456, 789, "👍").await?;
474    /// assert!(ok);
475    /// # Ok(())
476    /// # }
477    /// ```
478    #[cfg_attr(
479        feature = "profiling",
480        tracing::instrument(name = "channels.telegram.delete_message_reaction", skip_all)
481    )]
482    pub async fn delete_message_reaction(
483        &self,
484        chat_id: i64,
485        message_id: i64,
486        user_id: i64,
487        reaction: &str,
488    ) -> Result<bool, TelegramApiError> {
489        #[derive(Serialize)]
490        struct Req<'a> {
491            chat_id: i64,
492            message_id: i64,
493            user_id: i64,
494            reaction: &'a str,
495        }
496        self.post(
497            "deleteMessageReaction",
498            &Req {
499                chat_id,
500                message_id,
501                user_id,
502                reaction,
503            },
504        )
505        .await
506    }
507
508    /// Retrieve the membership status of `user_id` in `chat_id`.
509    ///
510    /// Used for a pre-flight admin check before executing moderation actions. The
511    /// result is not cached — each call makes a live API request.
512    ///
513    /// # Arguments
514    ///
515    /// * `chat_id` — identifier of the chat to query.
516    /// * `user_id` — identifier of the user whose membership to retrieve.
517    ///
518    /// # Errors
519    ///
520    /// Returns [`TelegramApiError`] on HTTP failure or when `ok: false`.
521    ///
522    /// # Examples
523    ///
524    /// ```no_run
525    /// use zeph_channels::telegram_api_ext::TelegramApiClient;
526    ///
527    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
528    /// let client = TelegramApiClient::new("TOKEN");
529    /// let member = client.get_chat_member(123, 456).await?;
530    /// println!("is admin: {}", member.is_admin());
531    /// # Ok(())
532    /// # }
533    /// ```
534    #[cfg_attr(
535        feature = "profiling",
536        tracing::instrument(name = "channels.telegram.get_chat_member", skip_all)
537    )]
538    pub async fn get_chat_member(
539        &self,
540        chat_id: i64,
541        user_id: i64,
542    ) -> Result<ChatMember, TelegramApiError> {
543        #[derive(Serialize)]
544        struct Req {
545            chat_id: i64,
546            user_id: i64,
547        }
548        self.post("getChatMember", &Req { chat_id, user_id }).await
549    }
550
551    /// Delete all reactions left by `user_id` on a message.
552    ///
553    /// Returns `true` on success.
554    ///
555    /// # Arguments
556    ///
557    /// * `chat_id` — identifier of the chat containing the message.
558    /// * `message_id` — identifier of the message.
559    /// * `user_id` — identifier of the user whose reactions to remove.
560    ///
561    /// # Errors
562    ///
563    /// Returns [`TelegramApiError`] on HTTP failure or when `ok: false`.
564    ///
565    /// # Examples
566    ///
567    /// ```no_run
568    /// use zeph_channels::telegram_api_ext::TelegramApiClient;
569    ///
570    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
571    /// let client = TelegramApiClient::new("TOKEN");
572    /// let ok = client.delete_all_message_reactions(123, 456, 789).await?;
573    /// assert!(ok);
574    /// # Ok(())
575    /// # }
576    /// ```
577    #[cfg_attr(
578        feature = "profiling",
579        tracing::instrument(name = "channels.telegram.delete_all_message_reactions", skip_all)
580    )]
581    pub async fn delete_all_message_reactions(
582        &self,
583        chat_id: i64,
584        message_id: i64,
585        user_id: i64,
586    ) -> Result<bool, TelegramApiError> {
587        #[derive(Serialize)]
588        #[allow(clippy::struct_field_names)]
589        struct Req {
590            chat_id: i64,
591            message_id: i64,
592            user_id: i64,
593        }
594        self.post(
595            "deleteAllMessageReactions",
596            &Req {
597                chat_id,
598                message_id,
599                user_id,
600            },
601        )
602        .await
603    }
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609    use wiremock::matchers::{method, path_regex};
610    use wiremock::{Mock, MockServer, ResponseTemplate};
611
612    fn ok_body(result: &serde_json::Value) -> serde_json::Value {
613        serde_json::json!({ "ok": true, "result": result })
614    }
615
616    fn err_body(description: &str) -> serde_json::Value {
617        serde_json::json!({ "ok": false, "description": description })
618    }
619
620    // ── Serde round-trip tests ────────────────────────────────────────────────
621
622    #[test]
623    fn sent_guest_message_round_trip() {
624        let original = SentGuestMessage {
625            message_id: 42,
626            chat_id: 100,
627        };
628        let json = serde_json::to_string(&original).unwrap();
629        let decoded: SentGuestMessage = serde_json::from_str(&json).unwrap();
630        assert_eq!(original, decoded);
631    }
632
633    #[test]
634    fn bot_access_settings_round_trip() {
635        let original = BotAccessSettings {
636            allow_user_messages: true,
637            allow_bot_messages: false,
638        };
639        let json = serde_json::to_string(&original).unwrap();
640        let decoded: BotAccessSettings = serde_json::from_str(&json).unwrap();
641        assert_eq!(original, decoded);
642    }
643
644    #[test]
645    fn guest_message_round_trip() {
646        let original = GuestMessage {
647            guest_query_id: "qid_abc".into(),
648            guest_bot_caller_user: GuestUser {
649                id: 123,
650                is_bot: false,
651                username: Some("alice".into()),
652                first_name: "Alice".into(),
653            },
654            guest_bot_caller_chat: GuestChat {
655                id: 999,
656                chat_type: "group".into(),
657            },
658            text: Some("hello".into()),
659        };
660        let json = serde_json::to_string(&original).unwrap();
661        let decoded: GuestMessage = serde_json::from_str(&json).unwrap();
662        assert_eq!(original, decoded);
663    }
664
665    #[test]
666    fn guest_message_round_trip_no_text() {
667        let original = GuestMessage {
668            guest_query_id: "qid_def".into(),
669            guest_bot_caller_user: GuestUser {
670                id: 1,
671                is_bot: false,
672                username: None,
673                first_name: "Bob".into(),
674            },
675            guest_bot_caller_chat: GuestChat {
676                id: 2,
677                chat_type: "supergroup".into(),
678            },
679            text: None,
680        };
681        let json = serde_json::to_string(&original).unwrap();
682        let decoded: GuestMessage = serde_json::from_str(&json).unwrap();
683        assert_eq!(original, decoded);
684    }
685
686    // ── answer_guest_query ────────────────────────────────────────────────────
687
688    #[tokio::test]
689    async fn answer_guest_query_success() {
690        let server = MockServer::start().await;
691        Mock::given(method("POST"))
692            .and(path_regex(".*/answerGuestQuery$"))
693            .respond_with(
694                ResponseTemplate::new(200).set_body_json(ok_body(&serde_json::json!({
695                    "message_id": 123,
696                    "chat_id": 456
697                }))),
698            )
699            .mount(&server)
700            .await;
701
702        let client = TelegramApiClient::with_base_url(server.uri());
703        let result = client
704            .answer_guest_query("qid", "hello", None)
705            .await
706            .unwrap();
707        assert_eq!(result.message_id, 123);
708        assert_eq!(result.chat_id, 456);
709    }
710
711    #[tokio::test]
712    async fn answer_guest_query_with_parse_mode() {
713        let server = MockServer::start().await;
714        Mock::given(method("POST"))
715            .and(path_regex(".*/answerGuestQuery$"))
716            .respond_with(
717                ResponseTemplate::new(200).set_body_json(ok_body(&serde_json::json!({
718                    "message_id": 1,
719                    "chat_id": 2
720                }))),
721            )
722            .mount(&server)
723            .await;
724
725        let client = TelegramApiClient::with_base_url(server.uri());
726        let result = client
727            .answer_guest_query("qid", "<b>bold</b>", Some("HTML"))
728            .await
729            .unwrap();
730        assert_eq!(result.message_id, 1);
731    }
732
733    #[tokio::test]
734    async fn answer_guest_query_api_error() {
735        let server = MockServer::start().await;
736        Mock::given(method("POST"))
737            .and(path_regex(".*/answerGuestQuery$"))
738            .respond_with(
739                ResponseTemplate::new(200).set_body_json(err_body("Bad Request: query not found")),
740            )
741            .mount(&server)
742            .await;
743
744        let client = TelegramApiClient::with_base_url(server.uri());
745        let err = client
746            .answer_guest_query("bad_id", "hi", None)
747            .await
748            .unwrap_err();
749        assert!(
750            matches!(err, TelegramApiError::Api(_)),
751            "expected Api error"
752        );
753    }
754
755    // ── get_managed_bot_access_settings ──────────────────────────────────────
756
757    #[tokio::test]
758    async fn get_managed_bot_access_settings_success() {
759        let server = MockServer::start().await;
760        Mock::given(method("POST"))
761            .and(path_regex(".*/getManagedBotAccessSettings$"))
762            .respond_with(
763                ResponseTemplate::new(200).set_body_json(ok_body(&serde_json::json!({
764                    "allow_user_messages": true,
765                    "allow_bot_messages": false
766                }))),
767            )
768            .mount(&server)
769            .await;
770
771        let client = TelegramApiClient::with_base_url(server.uri());
772        let settings = client.get_managed_bot_access_settings().await.unwrap();
773        assert!(settings.allow_user_messages);
774        assert!(!settings.allow_bot_messages);
775    }
776
777    #[tokio::test]
778    async fn get_managed_bot_access_settings_error() {
779        let server = MockServer::start().await;
780        Mock::given(method("POST"))
781            .and(path_regex(".*/getManagedBotAccessSettings$"))
782            .respond_with(
783                ResponseTemplate::new(200)
784                    .set_body_json(err_body("Forbidden: bot is not a member")),
785            )
786            .mount(&server)
787            .await;
788
789        let client = TelegramApiClient::with_base_url(server.uri());
790        let err = client.get_managed_bot_access_settings().await.unwrap_err();
791        assert!(matches!(err, TelegramApiError::Api(_)));
792    }
793
794    // ── set_managed_bot_access_settings ──────────────────────────────────────
795
796    #[tokio::test]
797    async fn set_managed_bot_access_settings_success() {
798        let server = MockServer::start().await;
799        Mock::given(method("POST"))
800            .and(path_regex(".*/setManagedBotAccessSettings$"))
801            .respond_with(
802                ResponseTemplate::new(200).set_body_json(ok_body(&serde_json::Value::Bool(true))),
803            )
804            .mount(&server)
805            .await;
806
807        let client = TelegramApiClient::with_base_url(server.uri());
808        let ok = client
809            .set_managed_bot_access_settings(&BotAccessSettings {
810                allow_user_messages: true,
811                allow_bot_messages: true,
812            })
813            .await
814            .unwrap();
815        assert!(ok);
816    }
817
818    #[tokio::test]
819    async fn set_managed_bot_access_settings_error() {
820        let server = MockServer::start().await;
821        Mock::given(method("POST"))
822            .and(path_regex(".*/setManagedBotAccessSettings$"))
823            .respond_with(
824                ResponseTemplate::new(200).set_body_json(err_body("Bad Request: invalid settings")),
825            )
826            .mount(&server)
827            .await;
828
829        let client = TelegramApiClient::with_base_url(server.uri());
830        let err = client
831            .set_managed_bot_access_settings(&BotAccessSettings {
832                allow_user_messages: false,
833                allow_bot_messages: false,
834            })
835            .await
836            .unwrap_err();
837        assert!(matches!(err, TelegramApiError::Api(_)));
838    }
839
840    // ── delete_message_reaction ───────────────────────────────────────────────
841
842    #[tokio::test]
843    async fn delete_message_reaction_success() {
844        let server = MockServer::start().await;
845        Mock::given(method("POST"))
846            .and(path_regex(".*/deleteMessageReaction$"))
847            .respond_with(
848                ResponseTemplate::new(200).set_body_json(ok_body(&serde_json::Value::Bool(true))),
849            )
850            .mount(&server)
851            .await;
852
853        let client = TelegramApiClient::with_base_url(server.uri());
854        let ok = client
855            .delete_message_reaction(100, 200, 300, "👍")
856            .await
857            .unwrap();
858        assert!(ok);
859    }
860
861    #[tokio::test]
862    async fn delete_message_reaction_error() {
863        let server = MockServer::start().await;
864        Mock::given(method("POST"))
865            .and(path_regex(".*/deleteMessageReaction$"))
866            .respond_with(
867                ResponseTemplate::new(200)
868                    .set_body_json(err_body("Bad Request: message not found")),
869            )
870            .mount(&server)
871            .await;
872
873        let client = TelegramApiClient::with_base_url(server.uri());
874        let err = client
875            .delete_message_reaction(1, 2, 3, "👎")
876            .await
877            .unwrap_err();
878        assert!(matches!(err, TelegramApiError::Api(_)));
879    }
880
881    // ── delete_all_message_reactions ─────────────────────────────────────────
882
883    #[tokio::test]
884    async fn delete_all_message_reactions_success() {
885        let server = MockServer::start().await;
886        Mock::given(method("POST"))
887            .and(path_regex(".*/deleteAllMessageReactions$"))
888            .respond_with(
889                ResponseTemplate::new(200).set_body_json(ok_body(&serde_json::Value::Bool(true))),
890            )
891            .mount(&server)
892            .await;
893
894        let client = TelegramApiClient::with_base_url(server.uri());
895        let ok = client
896            .delete_all_message_reactions(100, 200, 300)
897            .await
898            .unwrap();
899        assert!(ok);
900    }
901
902    #[tokio::test]
903    async fn delete_all_message_reactions_error() {
904        let server = MockServer::start().await;
905        Mock::given(method("POST"))
906            .and(path_regex(".*/deleteAllMessageReactions$"))
907            .respond_with(
908                ResponseTemplate::new(200).set_body_json(err_body("Forbidden: not enough rights")),
909            )
910            .mount(&server)
911            .await;
912
913        let client = TelegramApiClient::with_base_url(server.uri());
914        let err = client
915            .delete_all_message_reactions(1, 2, 3)
916            .await
917            .unwrap_err();
918        assert!(matches!(err, TelegramApiError::Api(_)));
919    }
920
921    // ── get_chat_member ───────────────────────────────────────────────────────
922
923    #[tokio::test]
924    async fn get_chat_member_administrator_success() {
925        let server = MockServer::start().await;
926        Mock::given(method("POST"))
927            .and(path_regex(".*/getChatMember$"))
928            .respond_with(
929                ResponseTemplate::new(200).set_body_json(ok_body(&serde_json::json!({
930                    "status": "administrator",
931                    "user": {
932                        "id": 456,
933                        "is_bot": false,
934                        "first_name": "Alice",
935                        "username": "alice"
936                    }
937                }))),
938            )
939            .mount(&server)
940            .await;
941
942        let client = TelegramApiClient::with_base_url(server.uri());
943        let member = client.get_chat_member(123, 456).await.unwrap();
944        assert!(member.is_admin());
945        assert_eq!(member.user.id, 456);
946    }
947
948    #[tokio::test]
949    async fn get_chat_member_creator_is_admin() {
950        let server = MockServer::start().await;
951        Mock::given(method("POST"))
952            .and(path_regex(".*/getChatMember$"))
953            .respond_with(
954                ResponseTemplate::new(200).set_body_json(ok_body(&serde_json::json!({
955                    "status": "creator",
956                    "user": {
957                        "id": 1,
958                        "is_bot": false,
959                        "first_name": "Owner"
960                    }
961                }))),
962            )
963            .mount(&server)
964            .await;
965
966        let client = TelegramApiClient::with_base_url(server.uri());
967        let member = client.get_chat_member(100, 1).await.unwrap();
968        assert!(member.is_admin());
969    }
970
971    #[tokio::test]
972    async fn get_chat_member_regular_member_is_not_admin() {
973        let server = MockServer::start().await;
974        Mock::given(method("POST"))
975            .and(path_regex(".*/getChatMember$"))
976            .respond_with(
977                ResponseTemplate::new(200).set_body_json(ok_body(&serde_json::json!({
978                    "status": "member",
979                    "user": {
980                        "id": 99,
981                        "is_bot": false,
982                        "first_name": "Bob"
983                    }
984                }))),
985            )
986            .mount(&server)
987            .await;
988
989        let client = TelegramApiClient::with_base_url(server.uri());
990        let member = client.get_chat_member(100, 99).await.unwrap();
991        assert!(!member.is_admin());
992    }
993
994    #[tokio::test]
995    async fn get_chat_member_api_error() {
996        let server = MockServer::start().await;
997        Mock::given(method("POST"))
998            .and(path_regex(".*/getChatMember$"))
999            .respond_with(
1000                ResponseTemplate::new(200).set_body_json(err_body("Bad Request: user not found")),
1001            )
1002            .mount(&server)
1003            .await;
1004
1005        let client = TelegramApiClient::with_base_url(server.uri());
1006        let err = client.get_chat_member(1, 2).await.unwrap_err();
1007        assert!(matches!(err, TelegramApiError::Api(_)));
1008    }
1009
1010    // ── get_me ────────────────────────────────────────────────────────────────
1011
1012    #[tokio::test]
1013    async fn get_me_success() {
1014        let server = MockServer::start().await;
1015        Mock::given(method("POST"))
1016            .and(path_regex(".*/getMe$"))
1017            .respond_with(
1018                ResponseTemplate::new(200).set_body_json(ok_body(&serde_json::json!({
1019                    "id": 123_456,
1020                    "is_bot": true,
1021                    "first_name": "MyBot",
1022                    "username": "my_bot"
1023                }))),
1024            )
1025            .mount(&server)
1026            .await;
1027
1028        let client = TelegramApiClient::with_base_url(server.uri());
1029        let me = client.get_me().await.unwrap();
1030        assert_eq!(me.id, 123_456);
1031        assert!(me.is_bot);
1032    }
1033
1034    // ── Timeout enforcement ───────────────────────────────────────────────────
1035
1036    #[tokio::test]
1037    async fn request_times_out_when_server_is_slow() {
1038        let server = MockServer::start().await;
1039        Mock::given(method("POST"))
1040            .and(path_regex(".*/getMe$"))
1041            .respond_with(
1042                ResponseTemplate::new(200)
1043                    .set_delay(std::time::Duration::from_millis(300))
1044                    .set_body_json(ok_body(&serde_json::json!({
1045                        "id": 1, "is_bot": true, "first_name": "Bot"
1046                    }))),
1047            )
1048            .mount(&server)
1049            .await;
1050
1051        // Use with_client() to inject a short timeout so the test stays fast.
1052        let short_timeout_client = reqwest::Client::builder()
1053            .timeout(std::time::Duration::from_millis(50))
1054            .build()
1055            .unwrap();
1056        let client = TelegramApiClient::with_client(short_timeout_client, "TOKEN");
1057        // Override base_url to point at the mock server.
1058        let mut client = client;
1059        client.base_url = server.uri();
1060
1061        let err = client.get_me().await.unwrap_err();
1062        assert!(
1063            matches!(err, TelegramApiError::Http(_)),
1064            "expected Http (timeout) error, got {err:?}"
1065        );
1066    }
1067
1068    // ── HTTP status error surfacing ───────────────────────────────────────────
1069
1070    #[tokio::test]
1071    async fn http_429_surfaces_as_http_error_not_serde_error() {
1072        let server = MockServer::start().await;
1073        Mock::given(method("POST"))
1074            .and(path_regex(".*/answerGuestQuery$"))
1075            .respond_with(ResponseTemplate::new(429).set_body_string("Too Many Requests"))
1076            .mount(&server)
1077            .await;
1078
1079        let client = TelegramApiClient::with_base_url(server.uri());
1080        let err = client
1081            .answer_guest_query("qid", "hi", None)
1082            .await
1083            .unwrap_err();
1084        assert!(
1085            matches!(err, TelegramApiError::Http(_)),
1086            "expected Http error for 429, got {err:?}"
1087        );
1088    }
1089}