foukoapi 0.1.0-alpha.1

Cross-platform bot framework in Rust. Write your handlers once, run the same bot on Telegram and Discord with shared accounts, embeds, keyboards and SQLite storage.
Documentation
//! Telegram adapter built on top of [`teloxide`].

use crate::{
    bot::Router,
    ctx::Ctx,
    error::{Error, Result},
    keyboard::{ButtonKind, Reply},
    platform::PlatformKind,
};
use std::sync::Arc;
use teloxide::{
    payloads::{EditMessageTextSetters, SendMessageSetters},
    prelude::*,
    types::{
        CallbackQuery, ChatId, InlineKeyboardButton, InlineKeyboardMarkup, Message, MessageId,
        ParseMode, ReplyParameters, ThreadId,
    },
};

/// Start the Telegram adapter. Blocks until the long-poll loop stops.
///
/// Honours the standard `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY`
/// environment variables. If any of them is set, the adapter routes its
/// Telegram API calls through that proxy - useful for regions where
/// `api.telegram.org` is blocked.
pub async fn run(token: String, router: Arc<Router>) -> Result<()> {
    tracing::info!("starting telegram adapter");
    let bot = build_bot(&token)?;
    let msg_router = router.clone();
    let cbq_router = router.clone();
    let handler = dptree::entry()
        .branch(
            Update::filter_message().endpoint(move |bot: teloxide::Bot, msg: Message| {
                let router = Arc::clone(&msg_router);
                async move {
                    if let Err(e) = handle_message(&bot, &msg, &router).await {
                        tracing::warn!(error = %e, "telegram handler error");
                    }
                    respond(())
                }
            }),
        )
        .branch(Update::filter_callback_query().endpoint(
            move |bot: teloxide::Bot, q: CallbackQuery| {
                let router = Arc::clone(&cbq_router);
                async move {
                    if let Err(e) = handle_callback(&bot, &q, &router).await {
                        tracing::warn!(error = %e, "telegram callback error");
                    }
                    respond(())
                }
            },
        ));

    Dispatcher::builder(bot, handler).build().dispatch().await;

    Ok(())
}

async fn handle_message(bot: &teloxide::Bot, msg: &Message, router: &Router) -> Result<()> {
    let Some(text) = msg.text() else {
        return Ok(());
    };

    let chat_id = msg.chat.id;
    let user_id = msg
        .from
        .as_ref()
        .map(|u| u.id.0.to_string())
        .unwrap_or_default();
    let is_dm = Some(msg.chat.is_private());
    // Keep the source message id and forum-topic id so every reply lands
    // as a real reply in the right topic / chat. Users in Telegram now
    // expect that behaviour - replies live in the thread they belong to.
    let source_msg_id = msg.id;
    let thread_id = msg.thread_id;

    let bot_clone = bot.clone();
    let reply_fn: crate::ctx::ReplyFn = Box::new(move |reply: Reply| {
        let bot = bot_clone.clone();
        Box::pin(async move {
            send_tg_message(&bot, chat_id, Some(source_msg_id), thread_id, reply).await
        })
    });

    let ctx = Ctx::new_full(
        PlatformKind::Telegram,
        chat_id.0.to_string(),
        user_id,
        text.to_owned(),
        reply_fn,
        is_dm,
        None,
    );

    router.dispatch(ctx).await
}

async fn handle_callback(bot: &teloxide::Bot, q: &CallbackQuery, router: &Router) -> Result<()> {
    // We need a chat to reply into. Messages attached to a callback carry
    // the originating chat; if the button came from inline mode without a
    // chat, we silently ack it and walk away.
    let Some(maybe_msg) = q.message.as_ref() else {
        // Best effort: acknowledge the press so the client stops spinning.
        let _ = bot.answer_callback_query(&q.id).await;
        return Ok(());
    };
    let chat_id = maybe_msg.chat().id;
    let user_id = q.from.id.0.to_string();
    let data = q.data.clone().unwrap_or_default();
    let is_dm = Some(maybe_msg.chat().is_private());
    // The attached message tells us which topic (if any) the user tapped
    // the button in. Reply in the same thread so the answer doesn't leak
    // into the general topic.
    let thread_id = thread_id_of(maybe_msg);
    // Message id of the bot's post that carries the button - we'll use
    // it to edit that message in place from handlers via ctx.edit_reply().
    let bot_msg_id = maybe_msg.id();

    // Acknowledge the press. Ignore errors - they don't affect dispatch.
    let _ = bot.answer_callback_query(&q.id).await;

    let bot_for_reply = bot.clone();
    let reply_fn: crate::ctx::ReplyFn = Box::new(move |reply: Reply| {
        let bot = bot_for_reply.clone();
        Box::pin(async move {
            // Thread new messages right off the button message so the
            // conversation stays tidy in groups and forums.
            send_tg_message(&bot, chat_id, Some(bot_msg_id), thread_id, reply).await
        })
    });
    let bot_for_edit = bot.clone();
    let edit_fn: crate::ctx::EditFn = std::sync::Arc::new(move |reply: Reply| {
        let bot = bot_for_edit.clone();
        Box::pin(async move { edit_tg_message(&bot, chat_id, bot_msg_id, reply).await })
    });

    // We pass the callback payload as both text (for handlers that key off
    // `ctx.text()` like slash-command dispatch) and as dedicated
    // `callback_data`. Slash-style payloads (`/confirm me`) still route
    // through `dispatch` like a typed message; free-form ones end up in
    // `Ctx::callback_data()`.
    let ctx = Ctx::new_with_edit(
        PlatformKind::Telegram,
        chat_id.0.to_string(),
        user_id,
        data.clone(),
        reply_fn,
        is_dm,
        Some(data),
        Some(edit_fn),
    );

    router.dispatch(ctx).await
}

/// Build a `teloxide::Bot` honouring proxy env vars.
fn build_bot(token: &str) -> Result<teloxide::Bot> {
    let proxy = std::env::var("HTTPS_PROXY")
        .ok()
        .or_else(|| std::env::var("https_proxy").ok())
        .or_else(|| std::env::var("HTTP_PROXY").ok())
        .or_else(|| std::env::var("http_proxy").ok())
        .or_else(|| std::env::var("ALL_PROXY").ok())
        .or_else(|| std::env::var("all_proxy").ok())
        .filter(|s| !s.trim().is_empty());

    let mut builder =
        reqwest::Client::builder().connect_timeout(std::time::Duration::from_secs(20));

    if let Some(url) = proxy.as_deref() {
        match reqwest::Proxy::all(url) {
            Ok(p) => {
                tracing::info!(proxy = %url, "telegram adapter using proxy");
                builder = builder.proxy(p);
            }
            Err(e) => {
                tracing::warn!(proxy = %url, error = %e, "ignoring bad proxy URL");
            }
        }
    }

    let client = builder
        .build()
        .map_err(|e| Error::platform("telegram", format!("reqwest client build: {e}")))?;
    Ok(teloxide::Bot::with_client(token, client))
}

/// Fish the forum-topic id out of a `MaybeInaccessibleMessage` that came
/// with a callback query. `None` when the message is inaccessible (very
/// old in a channel, deleted, etc.) or when the chat isn't a forum.
fn thread_id_of(maybe_msg: &teloxide::types::MaybeInaccessibleMessage) -> Option<ThreadId> {
    match maybe_msg {
        teloxide::types::MaybeInaccessibleMessage::Regular(m) => m.thread_id,
        teloxide::types::MaybeInaccessibleMessage::Inaccessible(_) => None,
    }
}

/// Send a [`Reply`] into a Telegram chat, optionally replying to a
/// specific message and/or pinning the reply to a forum topic.
///
/// Both are important in group chats:
/// - `reply_to` makes the bot's answer appear as a proper reply instead
///   of a loose message at the bottom of the chat;
/// - `thread_id` keeps the reply inside the forum topic the user typed
///   in, so a question in "General" doesn't get answered in "Random".
async fn send_tg_message(
    bot: &teloxide::Bot,
    chat_id: ChatId,
    reply_to: Option<MessageId>,
    thread_id: Option<ThreadId>,
    reply: Reply,
) -> Result<()> {
    // Telegram has no real embeds, but it *does* render HTML inside a
    // regular message. When the reply carries an Embed we dress it up
    // with <b> headers / dividers so the result still looks like a
    // "card" rather than a flat wall of text.
    let (body, use_html) = render_for_telegram(&reply);

    let mut req = bot.send_message(chat_id, body);
    if use_html {
        req = req.parse_mode(ParseMode::Html);
    }
    if let Some(id) = reply_to {
        // allow_sending_without_reply=true: if the original message is
        // gone (deleted / too old), still send a plain reply instead of
        // erroring out.
        req = req.reply_parameters(ReplyParameters::new(id).allow_sending_without_reply());
    }
    if let Some(tid) = thread_id {
        req = req.message_thread_id(tid);
    }
    if let Some(kb) = reply.get_keyboard() {
        req = req.reply_markup(to_tg_markup(kb));
    }
    req.await.map_err(|e| Error::platform("telegram", e))?;
    Ok(())
}

/// Edit the bot's message that carried the button the user just pressed.
///
/// Telegram's `editMessageText` + separate `editMessageReplyMarkup`
/// calls are used here: sending the keyboard as part of the text edit
/// is fine when we have one, but when the reply has no keyboard we
/// still want the old buttons gone, so the second call wipes the
/// markup. Errors fall through to the caller so the handler can decide
/// whether to fall back to a fresh message or give up.
async fn edit_tg_message(
    bot: &teloxide::Bot,
    chat_id: ChatId,
    msg_id: MessageId,
    reply: Reply,
) -> Result<()> {
    let (body, use_html) = render_for_telegram(&reply);
    let markup = reply.get_keyboard().map(to_tg_markup);
    let mut edit = bot.edit_message_text(chat_id, msg_id, body);
    if use_html {
        edit = edit.parse_mode(ParseMode::Html);
    }
    if let Some(m) = markup {
        edit = edit.reply_markup(m);
    }
    match edit.await {
        Ok(_) => Ok(()),
        Err(e) => {
            // "message is not modified" is a benign Telegram quirk: the
            // new text matched the old one exactly. Pretend we edited.
            if format!("{e}").contains("message is not modified") {
                return Ok(());
            }
            Err(Error::platform("telegram", e))
        }
    }
}

/// Render a [`Reply`] into the string teloxide actually sends, plus a
/// flag indicating whether HTML parsing should be enabled for it.
///
/// When there's no embed we keep things plain (Telegram escapes nothing
/// extra, auto-linkification handles URLs just fine). When there *is*
/// an embed we build an HTML block with a bold title, description,
/// `name: value` fields, and a small footer line - the closest we can
/// get to Discord's embed without leaving Telegram's formatting rules.
fn render_for_telegram(reply: &Reply) -> (String, bool) {
    let Some(em) = reply.get_embed() else {
        return (reply.get_text().to_owned(), false);
    };

    let mut out = String::new();

    // Title (linkified when the embed carries a URL).
    if let Some(title) = em.get_title() {
        let escaped = html_escape(title);
        match em.get_url() {
            Some(u) => out.push_str(&format!(
                "<b><a href=\"{}\">{escaped}</a></b>\n",
                html_escape(u)
            )),
            None => out.push_str(&format!("<b>{escaped}</b>\n")),
        }
    }
    if let Some(desc) = em.get_description() {
        out.push_str(&html_escape(desc));
        out.push('\n');
    }
    if !em.get_fields().is_empty() {
        if em.get_title().is_some() || em.get_description().is_some() {
            out.push('\n');
        }
        for f in em.get_fields() {
            out.push_str(&format!(
                "<b>{}</b>\n{}\n",
                html_escape(f.name()),
                html_escape(f.value())
            ));
        }
    }
    if let Some(foot) = em.get_footer() {
        out.push_str(&format!("\n<i>{}</i>", html_escape(foot)));
    }
    // Big image gets appended as a raw URL on a separate line - Telegram
    // auto-renders a preview for it. Thumbnails are discord-only, so we
    // skip those here.
    if let Some(img) = em.get_image() {
        out.push_str(&format!("\n\n{}", img));
    }
    // Prepend any free-form text that came along with the embed, so
    // handlers can still mix "quick line" + a pretty card in one call.
    if !reply.get_text().is_empty() {
        let head = html_escape(reply.get_text());
        out = format!("{head}\n\n{out}");
    }
    // Trim trailing whitespace so Telegram doesn't squint at us.
    while out.ends_with(|c: char| c.is_whitespace()) {
        out.pop();
    }
    (out, true)
}

fn html_escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            _ => out.push(c),
        }
    }
    out
}

fn to_tg_markup(kb: &crate::keyboard::Keyboard) -> InlineKeyboardMarkup {
    let rows: Vec<Vec<InlineKeyboardButton>> = kb
        .rows()
        .iter()
        .map(|row| {
            row.iter()
                .map(|btn| match &btn.kind {
                    ButtonKind::Callback(id) => {
                        InlineKeyboardButton::callback(btn.label().to_owned(), id.clone())
                    }
                    ButtonKind::Url(url) => InlineKeyboardButton::url(
                        btn.label().to_owned(),
                        url::Url::parse(url)
                            .unwrap_or_else(|_| url::Url::parse("https://fouko.xyz").unwrap()),
                    ),
                })
                .collect()
        })
        .collect();
    InlineKeyboardMarkup::new(rows)
}