ferobot 0.1.1

A fully-featured, auto-generated Telegram Bot API library for Rust. All 285 types and 165 methods - strongly typed, fully async.
Documentation
// Copyright (c) Ankit Chaubey <ankitchaubey.dev@gmail.com>
// SPDX-License-Identifier: MIT OR Apache-2.0
//
// ferobot: async Telegram Bot API framework written in Rust
// Repository: https://github.com/ankit-chaubey/ferobot
//
// Ferobot provides a fast and ergonomic framework for building Telegram bots
// using the official Telegram Bot API.
//
// Author: Ankit Chaubey
//
// If you use or modify this code, keep this notice at the top of your file
// and include the LICENSE-MIT or LICENSE-APACHE file from this repository.

use std::sync::Arc;

use serde::Deserialize;

use crate::{
    client::{BotClient, FormPart, ReqwestClient},
    input_file::{InputFile, InputFileOrString},
    types::User,
    BotError,
};

fn infer_mime(filename: &str) -> String {
    let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase();
    match ext.as_str() {
        "jpg" | "jpeg" => "image/jpeg",
        "png" => "image/png",
        "gif" => "image/gif",
        "webp" => "image/webp",
        "mp4" => "video/mp4",
        "mp3" => "audio/mpeg",
        "ogg" => "audio/ogg",
        "pdf" => "application/pdf",
        "webm" => "video/webm",
        _ => "application/octet-stream",
    }
    .to_string()
}

const DEFAULT_API_URL: &str = "https://api.telegram.org";

/// The main Bot struct. Create one per bot token.
///
/// # Example
/// ```rust,no_run
/// # use ferobot::Bot;
/// # #[tokio::main]
/// # async fn main() {
/// let bot = Bot::new("YOUR_TOKEN").await.unwrap();
/// println!("Running as @{}", bot.me.username.as_deref().unwrap_or(""));
/// # }
/// ```
///
/// # Debug output
///
/// The `Debug` implementation **masks the token** to avoid accidental leaks
/// in logs: `token: "12345:****"`. Use [`Bot::token()`] if you genuinely
/// need the full string.
#[derive(Clone)]
pub struct Bot {
    token: String,
    /// Bot info populated via `getMe` on creation.
    pub me: User,
    /// API base URL (default: `https://api.telegram.org`).
    pub api_url: String,
    pub(crate) base: String,
    /// Pluggable HTTP back-end. Defaults to [`ReqwestClient`].
    pub(crate) client: Arc<dyn BotClient>,
}

impl std::fmt::Debug for Bot {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let masked = match self.token.find(':') {
            Some(idx) => format!("{}:****", &self.token[..idx]),
            None => "****".to_string(),
        };
        f.debug_struct("Bot")
            .field("token", &masked)
            .field("me", &self.me)
            .field("api_url", &self.api_url)
            .finish()
    }
}

#[derive(Debug, Deserialize)]
struct TelegramResponse<T> {
    ok: bool,
    result: Option<T>,
    error_code: Option<i64>,
    description: Option<String>,
    parameters: Option<ResponseParameters>,
}

#[derive(Debug, Deserialize)]
struct ResponseParameters {
    migrate_to_chat_id: Option<i64>,
    retry_after: Option<i64>,
}

fn parse_bot_id(token: &str) -> Result<i64, BotError> {
    token
        .split(':')
        .next()
        .and_then(|s| s.parse::<i64>().ok())
        .ok_or(BotError::InvalidToken)
}

fn stub_user(id: i64) -> User {
    User {
        id,
        is_bot: true,
        first_name: String::new(),
        last_name: None,
        username: None,
        language_code: None,
        is_premium: None,
        added_to_attachment_menu: None,
        can_join_groups: None,
        can_read_all_group_messages: None,
        supports_inline_queries: None,
        can_connect_to_business: None,
        has_main_web_app: None,
        has_topics_enabled: None,
        allows_users_to_create_topics: None,
        can_manage_bots: None,
    }
}

impl Bot {
    /// Returns the bot token.
    ///
    /// The token is kept private to avoid accidental `Debug` leaks. Access it
    /// through this method when you genuinely need the raw string (e.g. to
    /// build a webhook URL or call a raw HTTP endpoint).
    pub fn token(&self) -> &str {
        &self.token
    }

    /// Create a new Bot and verify the token by calling `getMe`.
    pub async fn new(token: impl Into<String>) -> Result<Self, BotError> {
        Self::with_timeout(token, DEFAULT_API_URL, std::time::Duration::from_secs(30)).await
    }

    /// Create a Bot pointing at a custom API server. Calls `getMe` on creation.
    pub async fn with_api_url(
        token: impl Into<String>,
        api_url: impl Into<String>,
    ) -> Result<Self, BotError> {
        Self::with_timeout(token, api_url, std::time::Duration::from_secs(30)).await
    }

    /// Create a Bot with a custom HTTP timeout and API URL. Calls `getMe`.
    pub async fn with_timeout(
        token: impl Into<String>,
        api_url: impl Into<String>,
        timeout: std::time::Duration,
    ) -> Result<Self, BotError> {
        let token = token.into();
        let api_url = api_url.into();

        if !token.contains(':') {
            return Err(BotError::InvalidToken);
        }

        let bot_id = parse_bot_id(&token)?;
        let client = ReqwestClient::with_timeout(timeout)?;

        let base = format!("{}/bot{}/", api_url, token);
        let mut bot = Bot {
            token,
            me: stub_user(bot_id),
            api_url,
            base,
            client: Arc::new(client),
        };

        bot.me = bot.call_api("getMe", serde_json::json!({})).await?;
        Ok(bot)
    }

    /// Create a Bot with a **custom HTTP client** implementing [`BotClient`].
    ///
    /// The bot ID is parsed from the token so `bot.me.id` is valid.
    /// Call `getMe` yourself if you need the rest of the `me` fields.
    pub fn with_client(
        token: impl Into<String>,
        api_url: impl Into<String>,
        client: impl BotClient + 'static,
    ) -> Result<Self, BotError> {
        let token = token.into();
        if !token.contains(':') {
            return Err(BotError::InvalidToken);
        }
        let bot_id = parse_bot_id(&token)?;
        let api_url = api_url.into();
        let base = format!("{}/bot{}/", api_url, token);
        Ok(Bot {
            token,
            me: stub_user(bot_id),
            api_url,
            base,
            client: Arc::new(client),
        })
    }

    /// Create a Bot **without** calling `getMe` (no network on startup).
    ///
    /// The bot ID is parsed from the token so `bot.me.id` is always valid.
    /// All other `me` fields are left as zero-values until you call `getMe`.
    pub fn new_unverified(token: impl Into<String>) -> Result<Self, BotError> {
        let token = token.into();
        let bot_id = parse_bot_id(&token)?;
        let client = ReqwestClient::with_timeout(std::time::Duration::from_secs(30))
            .expect("default client should build");
        let api_url = DEFAULT_API_URL.to_string();
        let base = format!("{}/bot{}/", api_url, token);
        Ok(Bot {
            token,
            me: stub_user(bot_id),
            api_url,
            base,
            client: Arc::new(client),
        })
    }

    /// Build a `Bot` configured for outbound API calls (sendMessage, etc.).
    ///
    /// Call [`Bot::new`] first to verify the token, then swap the transport:
    ///
    /// ```rust,no_run
    /// # async fn example() -> Result<(), ferobot::BotError> {
    /// let verified = ferobot::Bot::new("TOKEN").await?;
    /// let api_bot = verified.into_api_bot()?;
    /// # Ok(())
    /// # }
    /// ```
    pub fn into_api_bot(self) -> Result<Self, BotError> {
        Ok(Bot {
            client: Arc::new(ReqwestClient::for_api()?),
            ..self
        })
    }

    /// Build a `Bot` configured for long-polling (`getUpdates`).
    ///
    /// Pass this to [`Poller::new`] and a separate `into_api_bot()` to
    /// [`Poller::api_bot`] so handler calls use a different connection pool.
    ///
    /// ```rust,no_run
    /// # async fn example() -> Result<(), ferobot::BotError> {
    /// let token = "TOKEN";
    /// let verified  = ferobot::Bot::new(token).await?;
    /// let poll_bot  = verified.clone().into_polling_bot()?;
    /// let api_bot   = verified.into_api_bot()?;
    /// # Ok(())
    /// # }
    /// ```
    pub fn into_polling_bot(self) -> Result<Self, BotError> {
        Ok(Bot {
            client: Arc::new(ReqwestClient::for_polling()?),
            ..self
        })
    }

    /// Build the full endpoint URL for a Telegram method name.
    pub fn endpoint(&self, method: &str) -> String {
        format!("{}{}", self.base, method)
    }

    /// Make a JSON API call and deserialize the result.
    pub async fn call_api<T>(&self, method: &str, body: serde_json::Value) -> Result<T, BotError>
    where
        T: for<'de> Deserialize<'de>,
    {
        let url = self.endpoint(method);
        let bytes = self.client.post_json(&url, body).await?;
        let tg: TelegramResponse<T> = serde_json::from_slice(&bytes)?;
        self.unwrap_response(tg)
    }

    /// Make an API call using multipart when a `Memory` file is present,
    /// JSON otherwise.
    pub async fn call_api_with_file<T>(
        &self,
        method: &str,
        body: serde_json::Map<String, serde_json::Value>,
        file_field: &str,
        file: InputFileOrString,
    ) -> Result<T, BotError>
    where
        T: for<'de> Deserialize<'de>,
    {
        match file {
            InputFileOrString::File(InputFile::Memory { filename, data }) => {
                let mime = infer_mime(&filename);

                let mut parts: Vec<FormPart> = body
                    .into_iter()
                    .filter(|(_, v)| !v.is_null())
                    .map(|(k, v)| {
                        let text = match &v {
                            serde_json::Value::String(s) => s.clone(),
                            other => other.to_string(),
                        };
                        FormPart::text(k, text)
                    })
                    .collect();

                parts.push(FormPart::bytes(file_field, filename, mime, data));
                self.call_api_multipart(method, parts).await
            }
            other => {
                let mut req = body;
                req.insert(
                    file_field.into(),
                    serde_json::to_value(other).unwrap_or_default(),
                );
                self.call_api(method, serde_json::Value::Object(req)).await
            }
        }
    }

    /// Make a `multipart/form-data` API call directly from [`FormPart`]s.
    pub async fn call_api_multipart<T>(
        &self,
        method: &str,
        parts: Vec<FormPart>,
    ) -> Result<T, BotError>
    where
        T: for<'de> Deserialize<'de>,
    {
        let url = self.endpoint(method);
        let bytes = self.client.post_form(&url, parts).await?;
        let tg: TelegramResponse<T> = serde_json::from_slice(&bytes)?;
        self.unwrap_response(tg)
    }

    fn unwrap_response<T>(&self, tg: TelegramResponse<T>) -> Result<T, BotError> {
        if tg.ok {
            tg.result
                .ok_or_else(|| BotError::Other("ok=true but result is null".into()))
        } else {
            Err(BotError::Api {
                code: tg.error_code.unwrap_or(0),
                description: tg.description.unwrap_or_else(|| "Unknown error".into()),
                retry_after: tg.parameters.as_ref().and_then(|p| p.retry_after),
                migrate_to_chat_id: tg.parameters.as_ref().and_then(|p| p.migrate_to_chat_id),
            })
        }
    }

    /// Call any Telegram Bot API method by name.
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # use ferobot::Bot;
    /// # #[tokio::main]
    /// # async fn main() -> Result<(), ferobot::BotError> {
    /// let bot = Bot::new("TOKEN").await?;
    ///
    /// // sendMessage with any params you like
    /// let msg: serde_json::Value = bot
    ///     .raw("sendMessage")
    ///     .param("chat_id", 123456789_i64)
    ///     .param("text", "Hello from raw! 🦀")
    ///     .param("parse_mode", "HTML")
    ///     .call()
    ///     .await?;
    ///
    /// println!("sent message_id = {}", msg["message_id"]);
    ///
    /// // Fire-and-forget (no result needed)
    /// bot.raw("deleteMessage")
    ///     .param("chat_id", 123456789_i64)
    ///     .param("message_id", msg["message_id"].as_i64().unwrap())
    ///     .send()
    ///     .await?;
    /// # Ok(())
    /// # }
    /// ```
    pub fn raw(&self, method: impl Into<String>) -> crate::raw::RawRequest<'_> {
        crate::raw::RawRequest::new(self, method)
    }
}