Skip to main content

botkit_telegram/
client.rs

1use botkit_core::{BotError, FileSource};
2use serde::de::DeserializeOwned;
3use zenwave::{Client, ResponseExt};
4
5use crate::types::{BotCommand, InlineKeyboardMarkup, ReplyMarkup, StickerSet};
6
7const API_BASE: &str = "https://api.telegram.org";
8
9/// Which `sendX` endpoint a media payload goes through — the endpoint name
10/// and the form field Telegram expects the file under.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum MediaKind {
13    /// `sendPhoto` — images rendered inline.
14    Photo,
15    /// `sendAnimation` — GIFs and looping video without sound.
16    Animation,
17    /// `sendVideo` — video files.
18    Video,
19    /// `sendAudio` — music/podcast files with player UI.
20    Audio,
21    /// `sendVoice` — voice-note bubble (`.ogg` OPUS).
22    Voice,
23    /// `sendDocument` — everything else, as a file attachment.
24    Document,
25    /// `sendSticker` — native sticker formats or a sticker `file_id`.
26    Sticker,
27}
28
29impl MediaKind {
30    /// The Bot API method and its file/form field name.
31    fn spec(self) -> (&'static str, &'static str) {
32        match self {
33            Self::Photo => ("sendPhoto", "photo"),
34            Self::Animation => ("sendAnimation", "animation"),
35            Self::Video => ("sendVideo", "video"),
36            Self::Audio => ("sendAudio", "audio"),
37            Self::Voice => ("sendVoice", "voice"),
38            Self::Document => ("sendDocument", "document"),
39            Self::Sticker => ("sendSticker", "sticker"),
40        }
41    }
42}
43
44/// The payload of a media upload — everything `send_upload` needs past the
45/// endpoint and chat id.
46struct Upload<'a> {
47    /// The form field Telegram expects the file under.
48    field: &'a str,
49    /// The file bytes or path.
50    file: FileSource,
51    /// File name hint for the upload part.
52    filename: &'a str,
53    /// Optional caption under the media.
54    caption: Option<&'a str>,
55    /// Forum topic to post into.
56    thread_id: Option<i64>,
57}
58
59/// A sticker being added to a set — the file plus its `InputSticker`
60/// metadata.
61pub struct NewSticker {
62    /// The sticker image/animation bytes (`.png`/`.webp` static, `.tgs`
63    /// animated, `.webm` video).
64    pub file: FileSource,
65    /// File name hint for the upload part.
66    pub filename: String,
67    /// Telegram sticker format: `static`, `animated`, or `video`.
68    pub format: &'static str,
69    /// The emoji the sticker is associated with.
70    pub emoji: String,
71    /// `replaceStickerInSet` only: `file_id` of the sticker being replaced.
72    old_file_id: Option<String>,
73}
74
75impl NewSticker {
76    /// A sticker file with its set metadata.
77    pub fn new(
78        file: FileSource,
79        filename: impl Into<String>,
80        format: &'static str,
81        emoji: impl Into<String>,
82    ) -> Self {
83        Self {
84            file,
85            filename: filename.into(),
86            format,
87            emoji: emoji.into(),
88            old_file_id: None,
89        }
90    }
91
92    /// Mark this as replacing `file_id` (for `replaceStickerInSet`).
93    fn with_old_file_id(mut self, file_id: &str) -> Self {
94        self.old_file_id = Some(file_id.to_string());
95        self
96    }
97}
98
99/// Telegram REST API client
100#[derive(Clone)]
101pub struct TelegramClient {
102    token: String,
103}
104
105impl TelegramClient {
106    /// Create a new Telegram client
107    pub fn new(token: impl Into<String>) -> Self {
108        install_crypto_provider();
109
110        Self {
111            token: token.into(),
112        }
113    }
114
115    /// Get the bot token
116    pub fn token(&self) -> &str {
117        &self.token
118    }
119
120    fn api_url(&self, method: &str) -> String {
121        format!("{}/bot{}/{}", API_BASE, self.token, method)
122    }
123
124    /// Map a transport error, keeping the bot token out of the message.
125    ///
126    /// Telegram authenticates by putting the token in the request path, so any
127    /// error that echoes the URL would otherwise leak it into logs.
128    fn api_error(&self, error: impl std::fmt::Display) -> BotError {
129        BotError::Api(error.to_string().replace(&self.token, "<token>"))
130    }
131
132    async fn post_json<T>(&self, method: &str, body: &serde_json::Value) -> Result<T, BotError>
133    where
134        T: DeserializeOwned,
135    {
136        let mut client = zenwave::client();
137        let response = client
138            .post(self.api_url(method))
139            .map_err(|e| self.api_error(e))?
140            .json_body(body)
141            .map_err(|e| self.api_error(e))?
142            .await
143            .map_err(|e| self.api_error(e))?;
144
145        self.decode_response(method, response).await
146    }
147
148    async fn post_multipart<T>(
149        &self,
150        method: &str,
151        content_type: String,
152        body: Vec<u8>,
153    ) -> Result<T, BotError>
154    where
155        T: DeserializeOwned,
156    {
157        let mut client = zenwave::client();
158        let response = client
159            .post(self.api_url(method))
160            .map_err(|e| self.api_error(e))?
161            .header("Content-Type", content_type)
162            .map_err(|e| self.api_error(e))?
163            .bytes_body(body)
164            .await
165            .map_err(|e| self.api_error(e))?;
166
167        self.decode_response(method, response).await
168    }
169
170    async fn decode_response<T>(
171        &self,
172        method: &str,
173        response: http_kit::Response,
174    ) -> Result<T, BotError>
175    where
176        T: DeserializeOwned,
177    {
178        // Telegram reports most failures as a 4xx whose body carries the real
179        // reason, so read the body before deciding what to report.
180        let status = response.status();
181        let body = response
182            .into_body()
183            .into_string()
184            .await
185            .map_err(|e| self.api_error(e))?;
186
187        parse_api_response(method, &body).map_err(|e| {
188            if status.is_success() {
189                e
190            } else {
191                BotError::Api(format!("Telegram {method} failed with HTTP {status}: {e}"))
192            }
193        })
194    }
195
196    /// Send a text message
197    ///
198    /// `thread_id` targets a forum topic. Returns the id of the message that
199    /// was sent.
200    pub async fn send_message(
201        &self,
202        chat_id: i64,
203        text: &str,
204        thread_id: Option<i64>,
205        reply_markup: Option<ReplyMarkup>,
206    ) -> Result<i64, BotError> {
207        self.send_message_inner(chat_id, text, None, thread_id, reply_markup)
208            .await
209    }
210
211    /// Send a text message as a reply to another message
212    ///
213    /// `reply_to` is the id of the message to quote. Telegram drops the reply
214    /// reference rather than failing when the target no longer exists
215    /// (`allow_sending_without_reply`), and posts the reply into the
216    /// referenced message's forum topic. Returns the sent message's id.
217    pub async fn send_reply(
218        &self,
219        chat_id: i64,
220        reply_to: i64,
221        text: &str,
222    ) -> Result<i64, BotError> {
223        self.send_reply_markup(chat_id, reply_to, text, None).await
224    }
225
226    /// [`Self::send_reply`] with an inline keyboard attached.
227    pub async fn send_reply_markup(
228        &self,
229        chat_id: i64,
230        reply_to: i64,
231        text: &str,
232        markup: Option<InlineKeyboardMarkup>,
233    ) -> Result<i64, BotError> {
234        self.send_message_inner(
235            chat_id,
236            text,
237            Some(reply_to),
238            None,
239            markup.map(ReplyMarkup::InlineKeyboard),
240        )
241        .await
242    }
243
244    async fn send_message_inner(
245        &self,
246        chat_id: i64,
247        text: &str,
248        reply_to: Option<i64>,
249        thread_id: Option<i64>,
250        reply_markup: Option<ReplyMarkup>,
251    ) -> Result<i64, BotError> {
252        let mut body = serde_json::json!({
253            "chat_id": chat_id,
254            "text": text,
255        });
256
257        if let Some(thread) = thread_id {
258            body["message_thread_id"] = serde_json::json!(thread);
259        }
260
261        if let Some(message_id) = reply_to {
262            body["reply_parameters"] = serde_json::json!({
263                "message_id": message_id,
264                "allow_sending_without_reply": true,
265            });
266        }
267
268        if let Some(markup) = reply_markup {
269            body["reply_markup"] = serde_json::to_value(markup)
270                .map_err(|e| BotError::Other(format!("failed to serialize reply markup: {e}")))?;
271        }
272
273        let message: crate::types::Message = self.post_json("sendMessage", &body).await?;
274        Ok(message.message_id)
275    }
276
277    /// Edit a message
278    pub async fn edit_message_text(
279        &self,
280        chat_id: i64,
281        message_id: i64,
282        text: &str,
283        reply_markup: Option<ReplyMarkup>,
284    ) -> Result<(), BotError> {
285        let mut body = serde_json::json!({
286            "chat_id": chat_id,
287            "message_id": message_id,
288            "text": text,
289        });
290
291        if let Some(markup) = reply_markup {
292            body["reply_markup"] = serde_json::to_value(markup)
293                .map_err(|e| BotError::Other(format!("failed to serialize reply markup: {e}")))?;
294        }
295
296        let _: serde_json::Value = self.post_json("editMessageText", &body).await?;
297        Ok(())
298    }
299
300    /// Edit only a message's inline keyboard. Called with `None` it is the
301    /// existence probe: Telegram replies "message is not modified" on a
302    /// live message and "message to edit not found" on a dead one.
303    pub async fn edit_message_reply_markup(
304        &self,
305        chat_id: i64,
306        message_id: i64,
307        reply_markup: Option<InlineKeyboardMarkup>,
308    ) -> Result<(), BotError> {
309        let mut body = serde_json::json!({
310            "chat_id": chat_id,
311            "message_id": message_id,
312        });
313        if let Some(markup) = reply_markup {
314            body["reply_markup"] = serde_json::to_value(markup)
315                .map_err(|e| BotError::Other(format!("failed to serialize reply markup: {e}")))?;
316        }
317        let _: serde_json::Value = self.post_json("editMessageReplyMarkup", &body).await?;
318        Ok(())
319    }
320
321    /// Answer a callback query
322    pub async fn answer_callback_query(
323        &self,
324        callback_query_id: &str,
325        text: Option<&str>,
326        show_alert: bool,
327    ) -> Result<(), BotError> {
328        let mut body = serde_json::json!({
329            "callback_query_id": callback_query_id,
330            "show_alert": show_alert,
331        });
332
333        if let Some(text) = text {
334            body["text"] = serde_json::json!(text);
335        }
336
337        let _: serde_json::Value = self.post_json("answerCallbackQuery", &body).await?;
338        Ok(())
339    }
340
341    /// Set webhook URL
342    pub async fn set_webhook(&self, url: &str) -> Result<(), BotError> {
343        let body = serde_json::json!({
344            "url": url,
345            "allowed_updates": [
346                "message",
347                "edited_message",
348                "callback_query",
349                "message_reaction"
350            ],
351        });
352
353        let _: serde_json::Value = self.post_json("setWebhook", &body).await?;
354        Ok(())
355    }
356
357    /// Delete webhook
358    pub async fn delete_webhook(&self) -> Result<(), BotError> {
359        let body = serde_json::json!({});
360
361        let _: serde_json::Value = self.post_json("deleteWebhook", &body).await?;
362        Ok(())
363    }
364
365    /// Get updates using long polling
366    pub async fn get_updates(
367        &self,
368        offset: Option<i64>,
369        timeout: Option<u32>,
370    ) -> Result<Vec<crate::types::Update>, BotError> {
371        let mut body = serde_json::json!({});
372
373        if let Some(offset) = offset {
374            body["offset"] = serde_json::json!(offset);
375        }
376        if let Some(timeout) = timeout {
377            body["timeout"] = serde_json::json!(timeout);
378        }
379        // `message_reaction` (and a few others) are only delivered when
380        // explicitly opted into — list every kind we model.
381        body["allowed_updates"] = serde_json::json!([
382            "message",
383            "edited_message",
384            "callback_query",
385            "message_reaction"
386        ]);
387
388        self.post_json("getUpdates", &body).await
389    }
390
391    /// Send a chat action (typing, uploading, etc.). `thread_id` targets a
392    /// forum topic.
393    pub async fn send_chat_action(
394        &self,
395        chat_id: i64,
396        action: &str,
397        thread_id: Option<i64>,
398    ) -> Result<(), BotError> {
399        let mut body = serde_json::json!({
400            "chat_id": chat_id,
401            "action": action,
402        });
403        if let Some(thread) = thread_id {
404            body["message_thread_id"] = serde_json::json!(thread);
405        }
406
407        let _: serde_json::Value = self.post_json("sendChatAction", &body).await?;
408        Ok(())
409    }
410
411    /// Set the bot's emoji reaction on a message. `None` removes the bot's
412    /// reaction; `is_big` plays the large animation.
413    pub async fn set_message_reaction(
414        &self,
415        chat_id: i64,
416        message_id: i64,
417        emoji: Option<&str>,
418        is_big: bool,
419    ) -> Result<(), BotError> {
420        let reactions: Vec<_> = emoji
421            .into_iter()
422            .map(|emoji| serde_json::json!({"type": "emoji", "emoji": emoji}))
423            .collect();
424        let body = serde_json::json!({
425            "chat_id": chat_id,
426            "message_id": message_id,
427            "reaction": reactions,
428            "is_big": is_big,
429        });
430        let _: serde_json::Value = self.post_json("setMessageReaction", &body).await?;
431        Ok(())
432    }
433
434    /// Delete a message — the bot's own, or any message in a chat where it
435    /// has delete rights.
436    pub async fn delete_message(&self, chat_id: i64, message_id: i64) -> Result<(), BotError> {
437        let _: serde_json::Value = self
438            .post_json(
439                "deleteMessage",
440                &serde_json::json!({"chat_id": chat_id, "message_id": message_id}),
441            )
442            .await?;
443        Ok(())
444    }
445
446    /// Pin a message (`notify = false` pins silently); `unpin` removes the
447    /// pin. Needs pin rights in groups.
448    pub async fn pin_message(
449        &self,
450        chat_id: i64,
451        message_id: i64,
452        notify: bool,
453    ) -> Result<(), BotError> {
454        let _: serde_json::Value = self
455            .post_json(
456                "pinChatMessage",
457                &serde_json::json!({
458                    "chat_id": chat_id,
459                    "message_id": message_id,
460                    "disable_notification": !notify,
461                }),
462            )
463            .await?;
464        Ok(())
465    }
466
467    /// See [`Self::pin_message`].
468    pub async fn unpin_message(&self, chat_id: i64, message_id: i64) -> Result<(), BotError> {
469        let _: serde_json::Value = self
470            .post_json(
471                "unpinChatMessage",
472                &serde_json::json!({"chat_id": chat_id, "message_id": message_id}),
473            )
474            .await?;
475        Ok(())
476    }
477
478    /// Set bot commands for the menu
479    ///
480    /// Registers commands with Telegram so they appear in the command menu.
481    pub async fn set_my_commands(&self, commands: &[BotCommand]) -> Result<(), BotError> {
482        let body = serde_json::json!({
483            "commands": commands,
484        });
485
486        let _: serde_json::Value = self.post_json("setMyCommands", &body).await?;
487        Ok(())
488    }
489
490    /// The bot's own identity. `id` is the `user_id` owner argument the
491    /// sticker-set methods take; `username` is the mandatory
492    /// `_by_<bot>` suffix for sets the bot creates.
493    pub async fn get_me(&self) -> Result<crate::types::User, BotError> {
494        self.post_json("getMe", &serde_json::json!({})).await
495    }
496
497    /// `getFile` — resolve a `file_id` to its server-side `file_path`.
498    pub async fn get_file(&self, file_id: &str) -> Result<crate::types::File, BotError> {
499        self.post_json("getFile", &serde_json::json!({"file_id": file_id}))
500            .await
501    }
502
503    /// Download a file's bytes using the `file_path` `getFile` returned.
504    ///
505    /// Telegram serves file content from a separate URL shape
506    /// (`/file/bot<token>/<path>`) and returns raw bytes rather than the
507    /// usual JSON envelope. `limit` caps the buffered size; files larger
508    /// than it error instead of truncating.
509    pub async fn download_file(&self, file_path: &str, limit: usize) -> Result<Vec<u8>, BotError> {
510        let url = format!("{}/file/bot{}/{}", API_BASE, self.token, file_path);
511        let response = zenwave::get(&url).await.map_err(|e| self.api_error(e))?;
512        let bytes = response
513            .error_for_status()
514            .await
515            .map_err(|e| self.api_error(e))?
516            .into_bytes_with_limit(limit)
517            .await
518            .map_err(|e| self.api_error(e))?;
519        Ok(bytes.to_vec())
520    }
521
522    /// Send a document/file.
523    ///
524    /// Returns the id of the message that was sent.
525    pub async fn send_document(
526        &self,
527        chat_id: i64,
528        file: FileSource,
529        filename: Option<&str>,
530        caption: Option<&str>,
531        thread_id: Option<i64>,
532    ) -> Result<i64, BotError> {
533        self.send_media(
534            chat_id,
535            MediaKind::Document,
536            file,
537            filename.unwrap_or("file"),
538            caption,
539            thread_id,
540        )
541        .await
542    }
543
544    /// Send a photo (JPEG, PNG, WebP, GIF, …)
545    ///
546    /// Returns the id of the message that was sent.
547    pub async fn send_photo(
548        &self,
549        chat_id: i64,
550        file: FileSource,
551        filename: &str,
552        caption: Option<&str>,
553        thread_id: Option<i64>,
554    ) -> Result<i64, BotError> {
555        self.send_media(
556            chat_id,
557            MediaKind::Photo,
558            file,
559            filename,
560            caption,
561            thread_id,
562        )
563        .await
564    }
565
566    /// Send a native sticker by upload (WebP, TGS, or WebM)
567    ///
568    /// Returns the id of the message that was sent.
569    pub async fn send_sticker(
570        &self,
571        chat_id: i64,
572        file: FileSource,
573        filename: &str,
574        thread_id: Option<i64>,
575    ) -> Result<i64, BotError> {
576        self.send_media(chat_id, MediaKind::Sticker, file, filename, None, thread_id)
577            .await
578    }
579
580    /// Upload `file` as the given media kind; returns the sent message's id.
581    /// `thread_id` targets a forum topic.
582    pub async fn send_media(
583        &self,
584        chat_id: i64,
585        kind: MediaKind,
586        file: FileSource,
587        filename: &str,
588        caption: Option<&str>,
589        thread_id: Option<i64>,
590    ) -> Result<i64, BotError> {
591        let (method, field) = kind.spec();
592        self.send_upload(
593            method,
594            chat_id,
595            Upload {
596                field,
597                file,
598                filename,
599                caption,
600                thread_id,
601            },
602        )
603        .await
604    }
605
606    /// Send media Telegram already hosts by `file_id` — anything the bot has
607    /// seen in a message or holds in a sticker set. `thread_id` targets a
608    /// forum topic. Returns the message id.
609    pub async fn send_media_id(
610        &self,
611        chat_id: i64,
612        kind: MediaKind,
613        file_id: &str,
614        caption: Option<&str>,
615        thread_id: Option<i64>,
616    ) -> Result<i64, BotError> {
617        let (method, field) = kind.spec();
618        let mut body = serde_json::json!({
619            "chat_id": chat_id,
620            field: file_id,
621        });
622        if let Some(caption) = caption {
623            body["caption"] = serde_json::json!(caption);
624        }
625        if let Some(thread) = thread_id {
626            body["message_thread_id"] = serde_json::json!(thread);
627        }
628        let message: crate::types::Message = self.post_json(method, &body).await?;
629        Ok(message.message_id)
630    }
631
632    /// Fetch a sticker set by short name.
633    ///
634    /// A missing set fails with a `BotError::Api` carrying Telegram's
635    /// `STICKERSET_INVALID` description.
636    pub async fn get_sticker_set(&self, name: &str) -> Result<StickerSet, BotError> {
637        self.post_json("getStickerSet", &serde_json::json!({"name": name}))
638            .await
639    }
640
641    /// Create a sticker set owned by `user_id` (the bot itself works) with
642    /// `sticker` as its first entry. `name` must end in `_by_<bot username>`.
643    pub async fn create_sticker_set(
644        &self,
645        user_id: i64,
646        name: &str,
647        title: &str,
648        sticker: NewSticker,
649    ) -> Result<(), BotError> {
650        self.sticker_set_edit("createNewStickerSet", user_id, name, Some(title), sticker)
651            .await
652    }
653
654    /// Append `sticker` to a set the bot owns.
655    pub async fn add_sticker_to_set(
656        &self,
657        user_id: i64,
658        name: &str,
659        sticker: NewSticker,
660    ) -> Result<(), BotError> {
661        self.sticker_set_edit("addStickerToSet", user_id, name, None, sticker)
662            .await
663    }
664
665    /// Replace the sticker `old_file_id` points at with `sticker`, keeping
666    /// its position in the set.
667    pub async fn replace_sticker_in_set(
668        &self,
669        user_id: i64,
670        name: &str,
671        old_file_id: &str,
672        sticker: NewSticker,
673    ) -> Result<(), BotError> {
674        self.sticker_set_edit(
675            "replaceStickerInSet",
676            user_id,
677            name,
678            None,
679            sticker.with_old_file_id(old_file_id),
680        )
681        .await
682    }
683
684    /// Shared multipart body for the sticker-set edit methods: the sticker
685    /// metadata goes as an `InputSticker` JSON referencing the uploaded
686    /// bytes through `attach://s0`.
687    async fn sticker_set_edit(
688        &self,
689        method: &str,
690        user_id: i64,
691        name: &str,
692        title: Option<&str>,
693        sticker: NewSticker,
694    ) -> Result<(), BotError> {
695        use zenwave::multipart::{Multipart, MultipartPart};
696
697        let contents = sticker
698            .file
699            .read()
700            .await
701            .map_err(|e| BotError::Other(format!("failed to read sticker file: {e}")))?;
702
703        let input = serde_json::json!({
704            "sticker": "attach://s0",
705            "format": sticker.format,
706            "emoji_list": [sticker.emoji],
707        });
708
709        let mut multipart = Multipart::new();
710        multipart.push(MultipartPart::text("user_id", user_id.to_string()));
711        multipart.push(MultipartPart::text("name", name));
712        if let Some(title) = title {
713            multipart.push(MultipartPart::text("title", title));
714            // createNewStickerSet wraps the InputSticker in a `stickers`
715            // array and wants the set's type; addStickerToSet takes the
716            // single object under `sticker`.
717            multipart.push(MultipartPart::text("sticker_type", "regular"));
718            multipart.push(MultipartPart::text(
719                "stickers",
720                serde_json::json!([input]).to_string(),
721            ));
722        } else {
723            multipart.push(MultipartPart::text("sticker", input.to_string()));
724            if let Some(old) = sticker.old_file_id {
725                multipart.push(MultipartPart::text("old_sticker", old));
726            }
727        }
728        let mime = mime_guess::from_path(&sticker.filename)
729            .first_or_octet_stream()
730            .to_string();
731        multipart.push(MultipartPart::binary(
732            "s0".to_owned(),
733            sticker.filename,
734            mime,
735            contents,
736        ));
737
738        let (boundary, body) = multipart.encode();
739        let content_type = format!("multipart/form-data; boundary={}", boundary);
740        let _: serde_json::Value = self.post_multipart(method, content_type, body).await?;
741        Ok(())
742    }
743
744    /// Upload a file with a multipart request to `method`. Returns the sent
745    /// message's id.
746    async fn send_upload(
747        &self,
748        method: &str,
749        chat_id: i64,
750        upload: Upload<'_>,
751    ) -> Result<i64, BotError> {
752        use zenwave::multipart::{Multipart, MultipartPart};
753
754        let contents = upload
755            .file
756            .read()
757            .await
758            .map_err(|e| BotError::Other(format!("failed to read attachment: {e}")))?;
759
760        let mut multipart = Multipart::new();
761        multipart.push(MultipartPart::text("chat_id", chat_id.to_string()));
762
763        if let Some(thread) = upload.thread_id {
764            multipart.push(MultipartPart::text("message_thread_id", thread.to_string()));
765        }
766
767        if let Some(caption) = upload.caption {
768            multipart.push(MultipartPart::text("caption", caption));
769        }
770
771        multipart.push(MultipartPart::binary(
772            upload.field.to_owned(),
773            upload.filename.to_owned(),
774            mime_guess::from_path(upload.filename)
775                .first_or_octet_stream()
776                .to_string(),
777            contents,
778        ));
779
780        let (boundary, body) = multipart.encode();
781        let content_type = format!("multipart/form-data; boundary={}", boundary);
782
783        let message: crate::types::Message =
784            self.post_multipart(method, content_type, body).await?;
785        Ok(message.message_id)
786    }
787}
788
789/// Make sure rustls has a process-wide crypto provider before any TLS happens.
790///
791/// rustls only auto-detects a provider when exactly one is compiled in. A bot
792/// that talks to Matrix *and* Discord or Telegram pulls both `ring` and
793/// `aws-lc-rs` into the build, and rustls then refuses to guess — it panics on
794/// the first handshake. Installing one explicitly is what keeps a unified bot
795/// working; whichever adapter gets there first wins, and the rest are no-ops.
796fn install_crypto_provider() {
797    if rustls::crypto::CryptoProvider::get_default().is_none() {
798        // Fails only if another thread won the race, which is just as good.
799        let _ = rustls::crypto::ring::default_provider().install_default();
800    }
801}
802
803#[derive(Debug, serde::Deserialize)]
804struct TelegramApiResponse<T> {
805    ok: bool,
806    result: Option<T>,
807    description: Option<String>,
808}
809
810fn parse_api_response<T>(method: &str, body: &str) -> Result<T, BotError>
811where
812    T: DeserializeOwned,
813{
814    let response: TelegramApiResponse<T> =
815        serde_json::from_str(body).map_err(|e| BotError::Api(e.to_string()))?;
816
817    if !response.ok {
818        let description = response
819            .description
820            .unwrap_or_else(|| format!("Telegram {method} failed without description"));
821        return Err(BotError::Api(description));
822    }
823
824    response
825        .result
826        .ok_or_else(|| BotError::Api(format!("Telegram {method} succeeded without result")))
827}
828
829#[cfg(test)]
830mod tests {
831    use super::{TelegramClient, parse_api_response};
832
833    #[test]
834    fn building_a_client_installs_a_crypto_provider() {
835        // Without this, a bot that also talks to Matrix panics on its first TLS
836        // handshake: both `ring` and `aws-lc-rs` end up compiled in and rustls
837        // refuses to pick one for you.
838        let _client = TelegramClient::new("token");
839        assert!(rustls::crypto::CryptoProvider::get_default().is_some());
840    }
841
842    #[test]
843    fn redacts_the_token_from_error_messages() {
844        // The token lives in every request path, so it must never reach a log.
845        let client = TelegramClient::new("123456:SECRET");
846        let error = client.api_error("connect to https://api.telegram.org/bot123456:SECRET/x");
847        assert!(!error.to_string().contains("SECRET"), "{error}");
848        assert!(error.to_string().contains("<token>"), "{error}");
849    }
850
851    #[test]
852    fn parses_successful_api_response() {
853        let updates: Vec<serde_json::Value> =
854            parse_api_response("getUpdates", r#"{"ok":true,"result":[{"update_id":1}]}"#).unwrap();
855        assert_eq!(updates.len(), 1);
856    }
857
858    #[test]
859    fn rejects_api_error_response() {
860        let err = parse_api_response::<serde_json::Value>(
861            "sendMessage",
862            r#"{"ok":false,"description":"chat not found"}"#,
863        )
864        .unwrap_err();
865        assert_eq!(err.to_string(), "API request failed: chat not found");
866    }
867}