1use std::time::Duration;
19
20use serde::{Deserialize, Serialize};
21
22const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
29pub struct SentGuestMessage {
30 pub message_id: i64,
32 pub chat_id: i64,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42pub struct BotAccessSettings {
43 pub allow_user_messages: bool,
45 pub allow_bot_messages: bool,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51pub struct GuestUser {
52 pub id: i64,
54 pub is_bot: bool,
56 pub username: Option<String>,
58 pub first_name: String,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
64pub struct GuestChat {
65 pub id: i64,
67 #[serde(rename = "type")]
69 pub chat_type: String,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
78pub struct GuestMessage {
79 pub guest_query_id: String,
81 pub guest_bot_caller_user: GuestUser,
83 pub guest_bot_caller_chat: GuestChat,
85 pub text: Option<String>,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
94#[serde(rename_all = "snake_case")]
95#[non_exhaustive]
96pub enum ChatMemberStatus {
97 Creator,
99 Administrator,
101 Member,
103 Restricted,
105 Left,
107 Kicked,
109 #[serde(other)]
111 Other,
112}
113
114#[derive(Debug, Clone, Deserialize)]
116pub struct ChatMember {
117 pub status: ChatMemberStatus,
119 pub user: GuestUser,
121}
122
123impl ChatMember {
124 #[must_use]
126 pub fn is_admin(&self) -> bool {
127 matches!(
128 self.status,
129 ChatMemberStatus::Creator | ChatMemberStatus::Administrator
130 )
131 }
132}
133
134#[derive(Deserialize)]
136struct TelegramResponse<T> {
137 ok: bool,
138 result: Option<T>,
139 description: Option<String>,
140}
141
142#[derive(Debug, thiserror::Error)]
144#[non_exhaustive]
145pub enum TelegramApiError {
146 #[error("HTTP error: {0}")]
148 Http(#[from] reqwest::Error),
149 #[error("Telegram API error: {0}")]
151 Api(String),
152}
153
154#[derive(Clone)]
172pub struct TelegramApiClient {
173 client: reqwest::Client,
174 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 #[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}