use std::time::Duration;
use serde::{Deserialize, Serialize};
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SentGuestMessage {
pub message_id: i64,
pub chat_id: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BotAccessSettings {
pub allow_user_messages: bool,
pub allow_bot_messages: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GuestUser {
pub id: i64,
pub is_bot: bool,
pub username: Option<String>,
pub first_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GuestChat {
pub id: i64,
#[serde(rename = "type")]
pub chat_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GuestMessage {
pub guest_query_id: String,
pub guest_bot_caller_user: GuestUser,
pub guest_bot_caller_chat: GuestChat,
pub text: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChatMemberStatus {
Creator,
Administrator,
Member,
Restricted,
Left,
Kicked,
#[serde(other)]
Other,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ChatMember {
pub status: ChatMemberStatus,
pub user: GuestUser,
}
impl ChatMember {
#[must_use]
pub fn is_admin(&self) -> bool {
matches!(
self.status,
ChatMemberStatus::Creator | ChatMemberStatus::Administrator
)
}
}
#[derive(Deserialize)]
struct TelegramResponse<T> {
ok: bool,
result: Option<T>,
description: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum TelegramApiError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("Telegram API error: {0}")]
Api(String),
}
#[derive(Clone)]
pub struct TelegramApiClient {
client: reqwest::Client,
base_url: String,
}
impl std::fmt::Debug for TelegramApiClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TelegramApiClient")
.field("base_url", &"[REDACTED]")
.finish_non_exhaustive()
}
}
impl TelegramApiClient {
#[must_use]
pub fn new(token: impl Into<String>) -> Self {
let token = token.into();
Self {
client: reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.expect("reqwest TLS backend unavailable"),
base_url: format!("https://api.telegram.org/bot{token}"),
}
}
#[must_use]
pub fn with_client(client: reqwest::Client, token: &str) -> Self {
Self {
client,
base_url: format!("https://api.telegram.org/bot{token}"),
}
}
pub async fn get_me(&self) -> Result<GuestUser, TelegramApiError> {
self.post("getMe", &serde_json::json!({})).await
}
#[must_use]
pub fn with_base_url(base_url: impl Into<String>) -> Self {
Self {
client: reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.expect("reqwest TLS backend unavailable"),
base_url: base_url.into(),
}
}
#[tracing::instrument(skip(self, body), fields(method = method))]
async fn post<T: serde::de::DeserializeOwned>(
&self,
method: &str,
body: &impl Serialize,
) -> Result<T, TelegramApiError> {
let url = format!("{}/{method}", self.base_url);
let resp: TelegramResponse<T> = self
.client
.post(&url)
.json(body)
.send()
.await
.map_err(|e| TelegramApiError::Http(e.without_url()))?
.error_for_status()
.map_err(|e| TelegramApiError::Http(e.without_url()))?
.json()
.await
.map_err(|e| TelegramApiError::Http(e.without_url()))?;
if resp.ok {
resp.result
.ok_or_else(|| TelegramApiError::Api("ok=true but no result".into()))
} else {
Err(TelegramApiError::Api(
resp.description.unwrap_or_else(|| "unknown error".into()),
))
}
}
pub async fn answer_guest_query(
&self,
query_id: &str,
text: &str,
parse_mode: Option<&str>,
) -> Result<SentGuestMessage, TelegramApiError> {
#[derive(Serialize)]
struct Req<'a> {
guest_query_id: &'a str,
text: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
parse_mode: Option<&'a str>,
}
self.post(
"answerGuestQuery",
&Req {
guest_query_id: query_id,
text,
parse_mode,
},
)
.await
}
pub async fn get_managed_bot_access_settings(
&self,
) -> Result<BotAccessSettings, TelegramApiError> {
self.post("getManagedBotAccessSettings", &serde_json::json!({}))
.await
}
pub async fn set_managed_bot_access_settings(
&self,
settings: &BotAccessSettings,
) -> Result<bool, TelegramApiError> {
self.post("setManagedBotAccessSettings", settings).await
}
pub async fn delete_message_reaction(
&self,
chat_id: i64,
message_id: i64,
user_id: i64,
reaction: &str,
) -> Result<bool, TelegramApiError> {
#[derive(Serialize)]
struct Req<'a> {
chat_id: i64,
message_id: i64,
user_id: i64,
reaction: &'a str,
}
self.post(
"deleteMessageReaction",
&Req {
chat_id,
message_id,
user_id,
reaction,
},
)
.await
}
pub async fn get_chat_member(
&self,
chat_id: i64,
user_id: i64,
) -> Result<ChatMember, TelegramApiError> {
#[derive(Serialize)]
struct Req {
chat_id: i64,
user_id: i64,
}
self.post("getChatMember", &Req { chat_id, user_id }).await
}
pub async fn delete_all_message_reactions(
&self,
chat_id: i64,
message_id: i64,
user_id: i64,
) -> Result<bool, TelegramApiError> {
#[derive(Serialize)]
#[allow(clippy::struct_field_names)]
struct Req {
chat_id: i64,
message_id: i64,
user_id: i64,
}
self.post(
"deleteAllMessageReactions",
&Req {
chat_id,
message_id,
user_id,
},
)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn ok_body(result: &serde_json::Value) -> serde_json::Value {
serde_json::json!({ "ok": true, "result": result })
}
fn err_body(description: &str) -> serde_json::Value {
serde_json::json!({ "ok": false, "description": description })
}
#[test]
fn sent_guest_message_round_trip() {
let original = SentGuestMessage {
message_id: 42,
chat_id: 100,
};
let json = serde_json::to_string(&original).unwrap();
let decoded: SentGuestMessage = serde_json::from_str(&json).unwrap();
assert_eq!(original, decoded);
}
#[test]
fn bot_access_settings_round_trip() {
let original = BotAccessSettings {
allow_user_messages: true,
allow_bot_messages: false,
};
let json = serde_json::to_string(&original).unwrap();
let decoded: BotAccessSettings = serde_json::from_str(&json).unwrap();
assert_eq!(original, decoded);
}
#[test]
fn guest_message_round_trip() {
let original = GuestMessage {
guest_query_id: "qid_abc".into(),
guest_bot_caller_user: GuestUser {
id: 123,
is_bot: false,
username: Some("alice".into()),
first_name: "Alice".into(),
},
guest_bot_caller_chat: GuestChat {
id: 999,
chat_type: "group".into(),
},
text: Some("hello".into()),
};
let json = serde_json::to_string(&original).unwrap();
let decoded: GuestMessage = serde_json::from_str(&json).unwrap();
assert_eq!(original, decoded);
}
#[test]
fn guest_message_round_trip_no_text() {
let original = GuestMessage {
guest_query_id: "qid_def".into(),
guest_bot_caller_user: GuestUser {
id: 1,
is_bot: false,
username: None,
first_name: "Bob".into(),
},
guest_bot_caller_chat: GuestChat {
id: 2,
chat_type: "supergroup".into(),
},
text: None,
};
let json = serde_json::to_string(&original).unwrap();
let decoded: GuestMessage = serde_json::from_str(&json).unwrap();
assert_eq!(original, decoded);
}
#[tokio::test]
async fn answer_guest_query_success() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(".*/answerGuestQuery$"))
.respond_with(
ResponseTemplate::new(200).set_body_json(ok_body(&serde_json::json!({
"message_id": 123,
"chat_id": 456
}))),
)
.mount(&server)
.await;
let client = TelegramApiClient::with_base_url(server.uri());
let result = client
.answer_guest_query("qid", "hello", None)
.await
.unwrap();
assert_eq!(result.message_id, 123);
assert_eq!(result.chat_id, 456);
}
#[tokio::test]
async fn answer_guest_query_with_parse_mode() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(".*/answerGuestQuery$"))
.respond_with(
ResponseTemplate::new(200).set_body_json(ok_body(&serde_json::json!({
"message_id": 1,
"chat_id": 2
}))),
)
.mount(&server)
.await;
let client = TelegramApiClient::with_base_url(server.uri());
let result = client
.answer_guest_query("qid", "<b>bold</b>", Some("HTML"))
.await
.unwrap();
assert_eq!(result.message_id, 1);
}
#[tokio::test]
async fn answer_guest_query_api_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(".*/answerGuestQuery$"))
.respond_with(
ResponseTemplate::new(200).set_body_json(err_body("Bad Request: query not found")),
)
.mount(&server)
.await;
let client = TelegramApiClient::with_base_url(server.uri());
let err = client
.answer_guest_query("bad_id", "hi", None)
.await
.unwrap_err();
assert!(
matches!(err, TelegramApiError::Api(_)),
"expected Api error"
);
}
#[tokio::test]
async fn get_managed_bot_access_settings_success() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(".*/getManagedBotAccessSettings$"))
.respond_with(
ResponseTemplate::new(200).set_body_json(ok_body(&serde_json::json!({
"allow_user_messages": true,
"allow_bot_messages": false
}))),
)
.mount(&server)
.await;
let client = TelegramApiClient::with_base_url(server.uri());
let settings = client.get_managed_bot_access_settings().await.unwrap();
assert!(settings.allow_user_messages);
assert!(!settings.allow_bot_messages);
}
#[tokio::test]
async fn get_managed_bot_access_settings_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(".*/getManagedBotAccessSettings$"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(err_body("Forbidden: bot is not a member")),
)
.mount(&server)
.await;
let client = TelegramApiClient::with_base_url(server.uri());
let err = client.get_managed_bot_access_settings().await.unwrap_err();
assert!(matches!(err, TelegramApiError::Api(_)));
}
#[tokio::test]
async fn set_managed_bot_access_settings_success() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(".*/setManagedBotAccessSettings$"))
.respond_with(
ResponseTemplate::new(200).set_body_json(ok_body(&serde_json::Value::Bool(true))),
)
.mount(&server)
.await;
let client = TelegramApiClient::with_base_url(server.uri());
let ok = client
.set_managed_bot_access_settings(&BotAccessSettings {
allow_user_messages: true,
allow_bot_messages: true,
})
.await
.unwrap();
assert!(ok);
}
#[tokio::test]
async fn set_managed_bot_access_settings_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(".*/setManagedBotAccessSettings$"))
.respond_with(
ResponseTemplate::new(200).set_body_json(err_body("Bad Request: invalid settings")),
)
.mount(&server)
.await;
let client = TelegramApiClient::with_base_url(server.uri());
let err = client
.set_managed_bot_access_settings(&BotAccessSettings {
allow_user_messages: false,
allow_bot_messages: false,
})
.await
.unwrap_err();
assert!(matches!(err, TelegramApiError::Api(_)));
}
#[tokio::test]
async fn delete_message_reaction_success() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(".*/deleteMessageReaction$"))
.respond_with(
ResponseTemplate::new(200).set_body_json(ok_body(&serde_json::Value::Bool(true))),
)
.mount(&server)
.await;
let client = TelegramApiClient::with_base_url(server.uri());
let ok = client
.delete_message_reaction(100, 200, 300, "👍")
.await
.unwrap();
assert!(ok);
}
#[tokio::test]
async fn delete_message_reaction_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(".*/deleteMessageReaction$"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(err_body("Bad Request: message not found")),
)
.mount(&server)
.await;
let client = TelegramApiClient::with_base_url(server.uri());
let err = client
.delete_message_reaction(1, 2, 3, "👎")
.await
.unwrap_err();
assert!(matches!(err, TelegramApiError::Api(_)));
}
#[tokio::test]
async fn delete_all_message_reactions_success() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(".*/deleteAllMessageReactions$"))
.respond_with(
ResponseTemplate::new(200).set_body_json(ok_body(&serde_json::Value::Bool(true))),
)
.mount(&server)
.await;
let client = TelegramApiClient::with_base_url(server.uri());
let ok = client
.delete_all_message_reactions(100, 200, 300)
.await
.unwrap();
assert!(ok);
}
#[tokio::test]
async fn delete_all_message_reactions_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(".*/deleteAllMessageReactions$"))
.respond_with(
ResponseTemplate::new(200).set_body_json(err_body("Forbidden: not enough rights")),
)
.mount(&server)
.await;
let client = TelegramApiClient::with_base_url(server.uri());
let err = client
.delete_all_message_reactions(1, 2, 3)
.await
.unwrap_err();
assert!(matches!(err, TelegramApiError::Api(_)));
}
#[tokio::test]
async fn get_chat_member_administrator_success() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(".*/getChatMember$"))
.respond_with(
ResponseTemplate::new(200).set_body_json(ok_body(&serde_json::json!({
"status": "administrator",
"user": {
"id": 456,
"is_bot": false,
"first_name": "Alice",
"username": "alice"
}
}))),
)
.mount(&server)
.await;
let client = TelegramApiClient::with_base_url(server.uri());
let member = client.get_chat_member(123, 456).await.unwrap();
assert!(member.is_admin());
assert_eq!(member.user.id, 456);
}
#[tokio::test]
async fn get_chat_member_creator_is_admin() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(".*/getChatMember$"))
.respond_with(
ResponseTemplate::new(200).set_body_json(ok_body(&serde_json::json!({
"status": "creator",
"user": {
"id": 1,
"is_bot": false,
"first_name": "Owner"
}
}))),
)
.mount(&server)
.await;
let client = TelegramApiClient::with_base_url(server.uri());
let member = client.get_chat_member(100, 1).await.unwrap();
assert!(member.is_admin());
}
#[tokio::test]
async fn get_chat_member_regular_member_is_not_admin() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(".*/getChatMember$"))
.respond_with(
ResponseTemplate::new(200).set_body_json(ok_body(&serde_json::json!({
"status": "member",
"user": {
"id": 99,
"is_bot": false,
"first_name": "Bob"
}
}))),
)
.mount(&server)
.await;
let client = TelegramApiClient::with_base_url(server.uri());
let member = client.get_chat_member(100, 99).await.unwrap();
assert!(!member.is_admin());
}
#[tokio::test]
async fn get_chat_member_api_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(".*/getChatMember$"))
.respond_with(
ResponseTemplate::new(200).set_body_json(err_body("Bad Request: user not found")),
)
.mount(&server)
.await;
let client = TelegramApiClient::with_base_url(server.uri());
let err = client.get_chat_member(1, 2).await.unwrap_err();
assert!(matches!(err, TelegramApiError::Api(_)));
}
#[tokio::test]
async fn get_me_success() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(".*/getMe$"))
.respond_with(
ResponseTemplate::new(200).set_body_json(ok_body(&serde_json::json!({
"id": 123_456,
"is_bot": true,
"first_name": "MyBot",
"username": "my_bot"
}))),
)
.mount(&server)
.await;
let client = TelegramApiClient::with_base_url(server.uri());
let me = client.get_me().await.unwrap();
assert_eq!(me.id, 123_456);
assert!(me.is_bot);
}
#[tokio::test]
async fn request_times_out_when_server_is_slow() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(".*/getMe$"))
.respond_with(
ResponseTemplate::new(200)
.set_delay(std::time::Duration::from_millis(300))
.set_body_json(ok_body(&serde_json::json!({
"id": 1, "is_bot": true, "first_name": "Bot"
}))),
)
.mount(&server)
.await;
let short_timeout_client = reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(50))
.build()
.unwrap();
let client = TelegramApiClient::with_client(short_timeout_client, "TOKEN");
let mut client = client;
client.base_url = server.uri();
let err = client.get_me().await.unwrap_err();
assert!(
matches!(err, TelegramApiError::Http(_)),
"expected Http (timeout) error, got {err:?}"
);
}
#[tokio::test]
async fn http_429_surfaces_as_http_error_not_serde_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(".*/answerGuestQuery$"))
.respond_with(ResponseTemplate::new(429).set_body_string("Too Many Requests"))
.mount(&server)
.await;
let client = TelegramApiClient::with_base_url(server.uri());
let err = client
.answer_guest_query("qid", "hi", None)
.await
.unwrap_err();
assert!(
matches!(err, TelegramApiError::Http(_)),
"expected Http error for 429, got {err:?}"
);
}
}