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.

//! Built-in webhook server (requires the `webhook` feature flag).
//!
//! ```toml
//! ferobot = { version = "0.1", features = ["webhook"] }
//! tokio   = { version = "1",   features = ["full"] }
//! ```
//!
//! Registers a `setWebhook` with Telegram, then starts an axum HTTP server that
//! receives `POST` requests, validates the secret token header, and dispatches
//! each [`Update`] to your [`UpdateHandler`] in a spawned task.
//!
//! Telegram retries if the server returns non-2xx or takes too long, so the
//! handler is always spawned - the endpoint returns `200 OK` immediately.
//!
//! # Example
//!
//! ```rust,no_run
//! use ferobot::{Bot, UpdateHandler, WebhookServer};
//!
//! #[tokio::main]
//! async fn main() {
//!     let bot = Bot::new("YOUR_TOKEN").await.unwrap();
//!
//!     let handler: UpdateHandler = Box::new(|bot, update| {
//!         Box::pin(async move {
//!             let Some(msg) = update.message else { return };
//!             let _ = bot.send_message(msg.chat.id, "Got it!").await;
//!         })
//!     });
//!
//!     WebhookServer::new(bot, handler)
//!         .port(8080)
//!         .secret_token("my_secret")
//!         .start("https://yourdomain.com")
//!         .await
//!         .unwrap();
//! }
//! ```

use crate::polling::UpdateHandler;
use crate::types::Update;
use crate::{Bot, BotError};

use axum::{
    extract::State,
    http::{HeaderMap, StatusCode},
    routing::post,
    Json, Router,
};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::Semaphore;
use tracing::{error, info, warn};

struct AppState {
    bot: Bot,
    handler: Arc<UpdateHandler>,
    secret_token: Option<String>,
    semaphore: Arc<Semaphore>,
}

/// A built-in HTTP server that receives Telegram webhook updates.
///
/// Same [`UpdateHandler`] interface as [`Poller`](crate::Poller) - swap one line
/// to switch between long-polling and webhooks.
pub struct WebhookServer {
    bot: Bot,
    handler: UpdateHandler,
    /// Local port to bind (default: `8080`).
    port: u16,
    /// URL path Telegram will POST to (default: `"/webhook"`).
    path: String,
    /// Optional secret sent in `X-Telegram-Bot-Api-Secret-Token`.
    secret_token: Option<String>,
    /// Update types to receive (empty = all).
    allowed_updates: Vec<String>,
    /// Max simultaneous connections Telegram may open (1-100).
    max_connections: Option<i64>,
    /// Drop pending updates when registering the webhook.
    drop_pending_updates: bool,
    /// Max handler tasks that may run concurrently (default: 512).
    max_concurrent: usize,
}

impl WebhookServer {
    pub fn new(bot: Bot, handler: UpdateHandler) -> Self {
        Self {
            bot,
            handler,
            port: 8080,
            path: "/webhook".to_string(),
            secret_token: None,
            allowed_updates: vec![],
            max_connections: None,
            drop_pending_updates: false,
            max_concurrent: 512,
        }
    }

    /// Set the local port to listen on (default: `8080`).
    ///
    /// Telegram supports ports 80, 88, 443, and 8443. Port 8080 works behind a reverse proxy.
    pub fn port(mut self, port: u16) -> Self {
        self.port = port;
        self
    }

    /// Set the URL path for webhook POSTs (default: `"/webhook"`).
    pub fn path(mut self, path: impl Into<String>) -> Self {
        self.path = path.into();
        self
    }

    /// Set a secret token for request validation (recommended in production).
    ///
    /// Requests without the correct `X-Telegram-Bot-Api-Secret-Token` header are rejected.
    pub fn secret_token(mut self, token: impl Into<String>) -> Self {
        self.secret_token = Some(token.into());
        self
    }

    /// Filter which update types to receive (default: all).
    pub fn allowed_updates(mut self, updates: Vec<String>) -> Self {
        self.allowed_updates = updates;
        self
    }

    /// Set max simultaneous connections Telegram opens (1-100, default 40).
    pub fn max_connections(mut self, n: i64) -> Self {
        self.max_connections = Some(n);
        self
    }

    /// Drop all pending updates when registering the webhook.
    pub fn drop_pending_updates(mut self) -> Self {
        self.drop_pending_updates = true;
        self
    }

    /// Set the maximum number of handler tasks that may run concurrently
    /// (default: `512`). Excess requests are queued by axum's HTTP layer
    /// Telegram retries them if they take too long, so keeping this bounded
    /// protects against memory exhaustion under traffic spikes.
    pub fn concurrency(mut self, max: usize) -> Self {
        self.max_concurrent = max.max(1);
        self
    }

    /// Register the webhook with Telegram and start the HTTP server.
    ///
    /// `webhook_url` is your public HTTPS base URL, e.g. `"https://mybot.example.com"`.
    /// The full webhook URL becomes `{webhook_url}{self.path}`.
    ///
    /// Blocks until the server shuts down.
    pub async fn start(self, webhook_url: &str) -> Result<(), BotError> {
        let full_url = format!("{}{}", webhook_url.trim_end_matches('/'), self.path);

        let mut req = self.bot.set_webhook(full_url.clone());
        if let Some(ref token) = self.secret_token {
            req = req.secret_token(token.clone());
        }
        if let Some(n) = self.max_connections {
            req = req.max_connections(n);
        }
        if !self.allowed_updates.is_empty() {
            req = req.allowed_updates(self.allowed_updates.clone());
        }
        if self.drop_pending_updates {
            req = req.drop_pending_updates(true);
        }
        req.await?;
        info!(url = %full_url, "webhook registered");

        let state = Arc::new(AppState {
            bot: self.bot,
            handler: Arc::new(self.handler),
            secret_token: self.secret_token,
            semaphore: Arc::new(Semaphore::new(self.max_concurrent)),
        });

        let app = Router::new()
            .route(&self.path, post(handle_update))
            .with_state(state);

        let addr = SocketAddr::from(([0, 0, 0, 0], self.port));
        info!(addr = %addr, "webhook server listening");

        let listener = tokio::net::TcpListener::bind(addr)
            .await
            .map_err(|e| BotError::Other(format!("Failed to bind port {}: {}", self.port, e)))?;

        axum::serve(listener, app)
            .await
            .map_err(|e| BotError::Other(format!("Webhook server error: {}", e)))?;

        Ok(())
    }
}

async fn handle_update(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Json(update): Json<Update>,
) -> StatusCode {
    if let Some(ref expected) = state.secret_token {
        let provided = headers
            .get("x-telegram-bot-api-secret-token")
            .and_then(|v| v.to_str().ok())
            .unwrap_or("");

        if provided != expected {
            warn!("invalid secret token - webhook request rejected");
            return StatusCode::FORBIDDEN;
        }
    }

    // Try to acquire a concurrency permit without blocking the axum task.
    // If all slots are taken, return 429 so Telegram retries after a back-off.
    // This is preferable to queuing inside the server and risking OOM.
    let permit = match state.semaphore.clone().try_acquire_owned() {
        Ok(p) => p,
        Err(_) => {
            warn!("concurrency limit reached - returning 429 so Telegram retries");
            return StatusCode::TOO_MANY_REQUESTS;
        }
    };

    let bot = state.bot.clone();
    let handler = Arc::clone(&state.handler);

    // Double-spawn for panic isolation + immediate 200 response.
    // Outer task: owns the semaphore permit, monitors the inner task.
    // Inner task: runs the user handler may panic freely.
    tokio::spawn(async move {
        let _permit = permit; // released when this task ends

        let result = tokio::spawn(async move {
            (handler)(bot, update).await;
        })
        .await;

        if let Err(join_err) = result {
            if join_err.is_panic() {
                error!("handler panicked on webhook update - task isolated, server continues");
            }
        }
    });

    StatusCode::OK
}