use botkit_core::{BotError, FileSource};
use serde::de::DeserializeOwned;
use zenwave::{Client, ResponseExt};
use crate::types::{BotCommand, InlineKeyboardMarkup, ReplyMarkup, StickerSet};
const API_BASE: &str = "https://api.telegram.org";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MediaKind {
Photo,
Animation,
Video,
Audio,
Voice,
Document,
Sticker,
}
impl MediaKind {
fn spec(self) -> (&'static str, &'static str) {
match self {
Self::Photo => ("sendPhoto", "photo"),
Self::Animation => ("sendAnimation", "animation"),
Self::Video => ("sendVideo", "video"),
Self::Audio => ("sendAudio", "audio"),
Self::Voice => ("sendVoice", "voice"),
Self::Document => ("sendDocument", "document"),
Self::Sticker => ("sendSticker", "sticker"),
}
}
}
struct Upload<'a> {
field: &'a str,
file: FileSource,
filename: &'a str,
caption: Option<&'a str>,
thread_id: Option<i64>,
}
pub struct NewSticker {
pub file: FileSource,
pub filename: String,
pub format: &'static str,
pub emoji: String,
old_file_id: Option<String>,
}
impl NewSticker {
pub fn new(
file: FileSource,
filename: impl Into<String>,
format: &'static str,
emoji: impl Into<String>,
) -> Self {
Self {
file,
filename: filename.into(),
format,
emoji: emoji.into(),
old_file_id: None,
}
}
fn with_old_file_id(mut self, file_id: &str) -> Self {
self.old_file_id = Some(file_id.to_string());
self
}
}
#[derive(Clone)]
pub struct TelegramClient {
token: String,
}
impl TelegramClient {
pub fn new(token: impl Into<String>) -> Self {
install_crypto_provider();
Self {
token: token.into(),
}
}
pub fn token(&self) -> &str {
&self.token
}
fn api_url(&self, method: &str) -> String {
format!("{}/bot{}/{}", API_BASE, self.token, method)
}
fn api_error(&self, error: impl std::fmt::Display) -> BotError {
BotError::Api(error.to_string().replace(&self.token, "<token>"))
}
async fn post_json<T>(&self, method: &str, body: &serde_json::Value) -> Result<T, BotError>
where
T: DeserializeOwned,
{
let mut client = zenwave::client();
let response = client
.post(self.api_url(method))
.map_err(|e| self.api_error(e))?
.json_body(body)
.map_err(|e| self.api_error(e))?
.await
.map_err(|e| self.api_error(e))?;
self.decode_response(method, response).await
}
async fn post_multipart<T>(
&self,
method: &str,
content_type: String,
body: Vec<u8>,
) -> Result<T, BotError>
where
T: DeserializeOwned,
{
let mut client = zenwave::client();
let response = client
.post(self.api_url(method))
.map_err(|e| self.api_error(e))?
.header("Content-Type", content_type)
.map_err(|e| self.api_error(e))?
.bytes_body(body)
.await
.map_err(|e| self.api_error(e))?;
self.decode_response(method, response).await
}
async fn decode_response<T>(
&self,
method: &str,
response: http_kit::Response,
) -> Result<T, BotError>
where
T: DeserializeOwned,
{
let status = response.status();
let body = response
.into_body()
.into_string()
.await
.map_err(|e| self.api_error(e))?;
parse_api_response(method, &body).map_err(|e| {
if status.is_success() {
e
} else {
BotError::Api(format!("Telegram {method} failed with HTTP {status}: {e}"))
}
})
}
pub async fn send_message(
&self,
chat_id: i64,
text: &str,
thread_id: Option<i64>,
reply_markup: Option<ReplyMarkup>,
) -> Result<i64, BotError> {
self.send_message_inner(chat_id, text, None, thread_id, reply_markup)
.await
}
pub async fn send_reply(
&self,
chat_id: i64,
reply_to: i64,
text: &str,
) -> Result<i64, BotError> {
self.send_reply_markup(chat_id, reply_to, text, None).await
}
pub async fn send_reply_markup(
&self,
chat_id: i64,
reply_to: i64,
text: &str,
markup: Option<InlineKeyboardMarkup>,
) -> Result<i64, BotError> {
self.send_message_inner(
chat_id,
text,
Some(reply_to),
None,
markup.map(ReplyMarkup::InlineKeyboard),
)
.await
}
async fn send_message_inner(
&self,
chat_id: i64,
text: &str,
reply_to: Option<i64>,
thread_id: Option<i64>,
reply_markup: Option<ReplyMarkup>,
) -> Result<i64, BotError> {
let mut body = serde_json::json!({
"chat_id": chat_id,
"text": text,
});
if let Some(thread) = thread_id {
body["message_thread_id"] = serde_json::json!(thread);
}
if let Some(message_id) = reply_to {
body["reply_parameters"] = serde_json::json!({
"message_id": message_id,
"allow_sending_without_reply": true,
});
}
if let Some(markup) = reply_markup {
body["reply_markup"] = serde_json::to_value(markup)
.map_err(|e| BotError::Other(format!("failed to serialize reply markup: {e}")))?;
}
let message: crate::types::Message = self.post_json("sendMessage", &body).await?;
Ok(message.message_id)
}
pub async fn edit_message_text(
&self,
chat_id: i64,
message_id: i64,
text: &str,
reply_markup: Option<ReplyMarkup>,
) -> Result<(), BotError> {
let mut body = serde_json::json!({
"chat_id": chat_id,
"message_id": message_id,
"text": text,
});
if let Some(markup) = reply_markup {
body["reply_markup"] = serde_json::to_value(markup)
.map_err(|e| BotError::Other(format!("failed to serialize reply markup: {e}")))?;
}
let _: serde_json::Value = self.post_json("editMessageText", &body).await?;
Ok(())
}
pub async fn edit_message_reply_markup(
&self,
chat_id: i64,
message_id: i64,
reply_markup: Option<InlineKeyboardMarkup>,
) -> Result<(), BotError> {
let mut body = serde_json::json!({
"chat_id": chat_id,
"message_id": message_id,
});
if let Some(markup) = reply_markup {
body["reply_markup"] = serde_json::to_value(markup)
.map_err(|e| BotError::Other(format!("failed to serialize reply markup: {e}")))?;
}
let _: serde_json::Value = self.post_json("editMessageReplyMarkup", &body).await?;
Ok(())
}
pub async fn answer_callback_query(
&self,
callback_query_id: &str,
text: Option<&str>,
show_alert: bool,
) -> Result<(), BotError> {
let mut body = serde_json::json!({
"callback_query_id": callback_query_id,
"show_alert": show_alert,
});
if let Some(text) = text {
body["text"] = serde_json::json!(text);
}
let _: serde_json::Value = self.post_json("answerCallbackQuery", &body).await?;
Ok(())
}
pub async fn set_webhook(&self, url: &str) -> Result<(), BotError> {
let body = serde_json::json!({
"url": url,
"allowed_updates": [
"message",
"edited_message",
"callback_query",
"message_reaction"
],
});
let _: serde_json::Value = self.post_json("setWebhook", &body).await?;
Ok(())
}
pub async fn delete_webhook(&self) -> Result<(), BotError> {
let body = serde_json::json!({});
let _: serde_json::Value = self.post_json("deleteWebhook", &body).await?;
Ok(())
}
pub async fn get_updates(
&self,
offset: Option<i64>,
timeout: Option<u32>,
) -> Result<Vec<crate::types::Update>, BotError> {
let mut body = serde_json::json!({});
if let Some(offset) = offset {
body["offset"] = serde_json::json!(offset);
}
if let Some(timeout) = timeout {
body["timeout"] = serde_json::json!(timeout);
}
body["allowed_updates"] = serde_json::json!([
"message",
"edited_message",
"callback_query",
"message_reaction"
]);
self.post_json("getUpdates", &body).await
}
pub async fn send_chat_action(
&self,
chat_id: i64,
action: &str,
thread_id: Option<i64>,
) -> Result<(), BotError> {
let mut body = serde_json::json!({
"chat_id": chat_id,
"action": action,
});
if let Some(thread) = thread_id {
body["message_thread_id"] = serde_json::json!(thread);
}
let _: serde_json::Value = self.post_json("sendChatAction", &body).await?;
Ok(())
}
pub async fn set_message_reaction(
&self,
chat_id: i64,
message_id: i64,
emoji: Option<&str>,
is_big: bool,
) -> Result<(), BotError> {
let reactions: Vec<_> = emoji
.into_iter()
.map(|emoji| serde_json::json!({"type": "emoji", "emoji": emoji}))
.collect();
let body = serde_json::json!({
"chat_id": chat_id,
"message_id": message_id,
"reaction": reactions,
"is_big": is_big,
});
let _: serde_json::Value = self.post_json("setMessageReaction", &body).await?;
Ok(())
}
pub async fn delete_message(&self, chat_id: i64, message_id: i64) -> Result<(), BotError> {
let _: serde_json::Value = self
.post_json(
"deleteMessage",
&serde_json::json!({"chat_id": chat_id, "message_id": message_id}),
)
.await?;
Ok(())
}
pub async fn pin_message(
&self,
chat_id: i64,
message_id: i64,
notify: bool,
) -> Result<(), BotError> {
let _: serde_json::Value = self
.post_json(
"pinChatMessage",
&serde_json::json!({
"chat_id": chat_id,
"message_id": message_id,
"disable_notification": !notify,
}),
)
.await?;
Ok(())
}
pub async fn unpin_message(&self, chat_id: i64, message_id: i64) -> Result<(), BotError> {
let _: serde_json::Value = self
.post_json(
"unpinChatMessage",
&serde_json::json!({"chat_id": chat_id, "message_id": message_id}),
)
.await?;
Ok(())
}
pub async fn set_my_commands(&self, commands: &[BotCommand]) -> Result<(), BotError> {
let body = serde_json::json!({
"commands": commands,
});
let _: serde_json::Value = self.post_json("setMyCommands", &body).await?;
Ok(())
}
pub async fn get_me(&self) -> Result<crate::types::User, BotError> {
self.post_json("getMe", &serde_json::json!({})).await
}
pub async fn get_file(&self, file_id: &str) -> Result<crate::types::File, BotError> {
self.post_json("getFile", &serde_json::json!({"file_id": file_id}))
.await
}
pub async fn download_file(&self, file_path: &str, limit: usize) -> Result<Vec<u8>, BotError> {
let url = format!("{}/file/bot{}/{}", API_BASE, self.token, file_path);
let response = zenwave::get(&url).await.map_err(|e| self.api_error(e))?;
let bytes = response
.error_for_status()
.await
.map_err(|e| self.api_error(e))?
.into_bytes_with_limit(limit)
.await
.map_err(|e| self.api_error(e))?;
Ok(bytes.to_vec())
}
pub async fn send_document(
&self,
chat_id: i64,
file: FileSource,
filename: Option<&str>,
caption: Option<&str>,
thread_id: Option<i64>,
) -> Result<i64, BotError> {
self.send_media(
chat_id,
MediaKind::Document,
file,
filename.unwrap_or("file"),
caption,
thread_id,
)
.await
}
pub async fn send_photo(
&self,
chat_id: i64,
file: FileSource,
filename: &str,
caption: Option<&str>,
thread_id: Option<i64>,
) -> Result<i64, BotError> {
self.send_media(
chat_id,
MediaKind::Photo,
file,
filename,
caption,
thread_id,
)
.await
}
pub async fn send_sticker(
&self,
chat_id: i64,
file: FileSource,
filename: &str,
thread_id: Option<i64>,
) -> Result<i64, BotError> {
self.send_media(chat_id, MediaKind::Sticker, file, filename, None, thread_id)
.await
}
pub async fn send_media(
&self,
chat_id: i64,
kind: MediaKind,
file: FileSource,
filename: &str,
caption: Option<&str>,
thread_id: Option<i64>,
) -> Result<i64, BotError> {
let (method, field) = kind.spec();
self.send_upload(
method,
chat_id,
Upload {
field,
file,
filename,
caption,
thread_id,
},
)
.await
}
pub async fn send_media_id(
&self,
chat_id: i64,
kind: MediaKind,
file_id: &str,
caption: Option<&str>,
thread_id: Option<i64>,
) -> Result<i64, BotError> {
let (method, field) = kind.spec();
let mut body = serde_json::json!({
"chat_id": chat_id,
field: file_id,
});
if let Some(caption) = caption {
body["caption"] = serde_json::json!(caption);
}
if let Some(thread) = thread_id {
body["message_thread_id"] = serde_json::json!(thread);
}
let message: crate::types::Message = self.post_json(method, &body).await?;
Ok(message.message_id)
}
pub async fn get_sticker_set(&self, name: &str) -> Result<StickerSet, BotError> {
self.post_json("getStickerSet", &serde_json::json!({"name": name}))
.await
}
pub async fn create_sticker_set(
&self,
user_id: i64,
name: &str,
title: &str,
sticker: NewSticker,
) -> Result<(), BotError> {
self.sticker_set_edit("createNewStickerSet", user_id, name, Some(title), sticker)
.await
}
pub async fn add_sticker_to_set(
&self,
user_id: i64,
name: &str,
sticker: NewSticker,
) -> Result<(), BotError> {
self.sticker_set_edit("addStickerToSet", user_id, name, None, sticker)
.await
}
pub async fn replace_sticker_in_set(
&self,
user_id: i64,
name: &str,
old_file_id: &str,
sticker: NewSticker,
) -> Result<(), BotError> {
self.sticker_set_edit(
"replaceStickerInSet",
user_id,
name,
None,
sticker.with_old_file_id(old_file_id),
)
.await
}
async fn sticker_set_edit(
&self,
method: &str,
user_id: i64,
name: &str,
title: Option<&str>,
sticker: NewSticker,
) -> Result<(), BotError> {
use zenwave::multipart::{Multipart, MultipartPart};
let contents = sticker
.file
.read()
.await
.map_err(|e| BotError::Other(format!("failed to read sticker file: {e}")))?;
let input = serde_json::json!({
"sticker": "attach://s0",
"format": sticker.format,
"emoji_list": [sticker.emoji],
});
let mut multipart = Multipart::new();
multipart.push(MultipartPart::text("user_id", user_id.to_string()));
multipart.push(MultipartPart::text("name", name));
if let Some(title) = title {
multipart.push(MultipartPart::text("title", title));
multipart.push(MultipartPart::text("sticker_type", "regular"));
multipart.push(MultipartPart::text(
"stickers",
serde_json::json!([input]).to_string(),
));
} else {
multipart.push(MultipartPart::text("sticker", input.to_string()));
if let Some(old) = sticker.old_file_id {
multipart.push(MultipartPart::text("old_sticker", old));
}
}
let mime = mime_guess::from_path(&sticker.filename)
.first_or_octet_stream()
.to_string();
multipart.push(MultipartPart::binary(
"s0".to_owned(),
sticker.filename,
mime,
contents,
));
let (boundary, body) = multipart.encode();
let content_type = format!("multipart/form-data; boundary={}", boundary);
let _: serde_json::Value = self.post_multipart(method, content_type, body).await?;
Ok(())
}
async fn send_upload(
&self,
method: &str,
chat_id: i64,
upload: Upload<'_>,
) -> Result<i64, BotError> {
use zenwave::multipart::{Multipart, MultipartPart};
let contents = upload
.file
.read()
.await
.map_err(|e| BotError::Other(format!("failed to read attachment: {e}")))?;
let mut multipart = Multipart::new();
multipart.push(MultipartPart::text("chat_id", chat_id.to_string()));
if let Some(thread) = upload.thread_id {
multipart.push(MultipartPart::text("message_thread_id", thread.to_string()));
}
if let Some(caption) = upload.caption {
multipart.push(MultipartPart::text("caption", caption));
}
multipart.push(MultipartPart::binary(
upload.field.to_owned(),
upload.filename.to_owned(),
mime_guess::from_path(upload.filename)
.first_or_octet_stream()
.to_string(),
contents,
));
let (boundary, body) = multipart.encode();
let content_type = format!("multipart/form-data; boundary={}", boundary);
let message: crate::types::Message =
self.post_multipart(method, content_type, body).await?;
Ok(message.message_id)
}
}
fn install_crypto_provider() {
if rustls::crypto::CryptoProvider::get_default().is_none() {
let _ = rustls::crypto::ring::default_provider().install_default();
}
}
#[derive(Debug, serde::Deserialize)]
struct TelegramApiResponse<T> {
ok: bool,
result: Option<T>,
description: Option<String>,
}
fn parse_api_response<T>(method: &str, body: &str) -> Result<T, BotError>
where
T: DeserializeOwned,
{
let response: TelegramApiResponse<T> =
serde_json::from_str(body).map_err(|e| BotError::Api(e.to_string()))?;
if !response.ok {
let description = response
.description
.unwrap_or_else(|| format!("Telegram {method} failed without description"));
return Err(BotError::Api(description));
}
response
.result
.ok_or_else(|| BotError::Api(format!("Telegram {method} succeeded without result")))
}
#[cfg(test)]
mod tests {
use super::{TelegramClient, parse_api_response};
#[test]
fn building_a_client_installs_a_crypto_provider() {
let _client = TelegramClient::new("token");
assert!(rustls::crypto::CryptoProvider::get_default().is_some());
}
#[test]
fn redacts_the_token_from_error_messages() {
let client = TelegramClient::new("123456:SECRET");
let error = client.api_error("connect to https://api.telegram.org/bot123456:SECRET/x");
assert!(!error.to_string().contains("SECRET"), "{error}");
assert!(error.to_string().contains("<token>"), "{error}");
}
#[test]
fn parses_successful_api_response() {
let updates: Vec<serde_json::Value> =
parse_api_response("getUpdates", r#"{"ok":true,"result":[{"update_id":1}]}"#).unwrap();
assert_eq!(updates.len(), 1);
}
#[test]
fn rejects_api_error_response() {
let err = parse_api_response::<serde_json::Value>(
"sendMessage",
r#"{"ok":false,"description":"chat not found"}"#,
)
.unwrap_err();
assert_eq!(err.to_string(), "API request failed: chat not found");
}
}