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
//! Context passed to every command handler.

use crate::{keyboard::Reply, platform::PlatformKind, Result};
use std::sync::Arc;

/// A lightweight handle to an incoming update.
///
/// A `Ctx` is what every handler receives. It exposes the platform the
/// message came from, the raw text, and a [`reply`](Ctx::reply) helper
/// that sends a message back through the same platform. For updates
/// that came from a button press it also carries an optional
/// [`edit_reply`](Ctx::edit_reply) that rewrites the original message
/// in place instead of sending a new one.
#[derive(Clone)]
pub struct Ctx {
    pub(crate) inner: Arc<CtxInner>,
}

pub(crate) struct CtxInner {
    pub(crate) platform: PlatformKind,
    pub(crate) chat_id: String,
    pub(crate) user_id: String,
    pub(crate) text: String,
    pub(crate) reply_fn: ReplyFn,
    /// `Some(true)` / `Some(false)` when the adapter knows for sure,
    /// `None` when it doesn't. Handlers that need a definitive answer
    /// should fall back to chat_id/user_id heuristics themselves.
    pub(crate) is_dm: Option<bool>,
    /// When this update came from a button press, the callback id the
    /// adapter associated with the pressed button. `None` for regular
    /// text messages.
    pub(crate) callback_data: Option<String>,
    /// When the update came from a button press, this callback lets a
    /// handler rewrite the **bot's own message** that carried the
    /// button, instead of sending a new message. Adapters set it only
    /// on callback updates where the original message is still
    /// reachable.
    pub(crate) edit_fn: Option<EditFn>,
}

/// Adapter-provided send callback. Takes a fully built [`Reply`] and sends
/// it back through the underlying platform.
pub type ReplyFn =
    Box<dyn Fn(Reply) -> futures::future::BoxFuture<'static, Result<()>> + Send + Sync + 'static>;

/// Adapter-provided edit callback. Rewrites the bot's message that
/// carried the button the user just pressed.
pub type EditFn =
    Arc<dyn Fn(Reply) -> futures::future::BoxFuture<'static, Result<()>> + Send + Sync + 'static>;

impl Ctx {
    /// Build a `Ctx` from its parts. Adapters use this when they receive an
    /// update from their underlying client.
    pub fn new(
        platform: PlatformKind,
        chat_id: impl Into<String>,
        user_id: impl Into<String>,
        text: impl Into<String>,
        reply_fn: ReplyFn,
    ) -> Self {
        Self {
            inner: Arc::new(CtxInner {
                platform,
                chat_id: chat_id.into(),
                user_id: user_id.into(),
                text: text.into(),
                reply_fn,
                is_dm: None,
                callback_data: None,
                edit_fn: None,
            }),
        }
    }

    /// Builder-style variant of [`Ctx::new`] that also records whether the
    /// update came from a direct message. Adapters that can tell for sure
    /// should use this; others keep passing `None` via [`Ctx::new`].
    pub fn new_full(
        platform: PlatformKind,
        chat_id: impl Into<String>,
        user_id: impl Into<String>,
        text: impl Into<String>,
        reply_fn: ReplyFn,
        is_dm: Option<bool>,
        callback_data: Option<String>,
    ) -> Self {
        Self {
            inner: Arc::new(CtxInner {
                platform,
                chat_id: chat_id.into(),
                user_id: user_id.into(),
                text: text.into(),
                reply_fn,
                is_dm,
                callback_data,
                edit_fn: None,
            }),
        }
    }

    /// Same as [`Ctx::new_full`], plus an `edit_fn` that rewrites the
    /// original message. Adapters use this on callback updates.
    #[allow(clippy::too_many_arguments)]
    pub fn new_with_edit(
        platform: PlatformKind,
        chat_id: impl Into<String>,
        user_id: impl Into<String>,
        text: impl Into<String>,
        reply_fn: ReplyFn,
        is_dm: Option<bool>,
        callback_data: Option<String>,
        edit_fn: Option<EditFn>,
    ) -> Self {
        Self {
            inner: Arc::new(CtxInner {
                platform,
                chat_id: chat_id.into(),
                user_id: user_id.into(),
                text: text.into(),
                reply_fn,
                is_dm,
                callback_data,
                edit_fn,
            }),
        }
    }

    /// Which platform sent this update.
    pub fn platform(&self) -> PlatformKind {
        self.inner.platform
    }

    /// The chat/channel id this update came from, as a string so it's
    /// platform-agnostic.
    pub fn chat_id(&self) -> &str {
        &self.inner.chat_id
    }

    /// Id of the user who sent this update.
    pub fn user_id(&self) -> &str {
        &self.inner.user_id
    }

    /// Raw text of the incoming message.
    pub fn text(&self) -> &str {
        &self.inner.text
    }

    /// Everything after the command, trimmed.
    ///
    /// For `"/roll 3d6"` this returns `"3d6"`.
    /// For `"/help"` it returns `""`.
    pub fn args(&self) -> &str {
        self.inner
            .text
            .split_once(char::is_whitespace)
            .map(|(_, rest)| rest.trim())
            .unwrap_or("")
    }

    /// `true` when we are certain this update is a direct/private chat with
    /// the bot (one-on-one conversation). `false` when it's definitely a
    /// group/channel. Adapters that can't tell return `false` conservatively,
    /// so treat the answer as "is this safe to post private info into?".
    pub fn is_dm(&self) -> bool {
        match self.inner.is_dm {
            Some(v) => v,
            None => {
                // Fallback heuristic. Telegram uses the same id for private
                // chat and user, so they happen to match. Discord/Matrix
                // channels have distinct ids, so we bail.
                matches!(self.inner.platform, PlatformKind::Telegram)
                    && self.inner.chat_id == self.inner.user_id
            }
        }
    }

    /// Callback data attached to this update, when the user pressed a
    /// button registered via [`crate::Keyboard`]. `None` for regular text.
    pub fn callback_data(&self) -> Option<&str> {
        self.inner.callback_data.as_deref()
    }

    /// `true` when this update came from a button press (as opposed to
    /// a typed message). Convenience wrapper around
    /// [`Ctx::callback_data`].
    pub fn is_callback(&self) -> bool {
        self.inner.callback_data.is_some()
    }

    /// Send a plain-text reply back to the same chat on the same platform.
    pub async fn reply(&self, text: impl Into<String>) -> Result<()> {
        (self.inner.reply_fn)(Reply::text(text)).await
    }

    /// Send a reply with attached buttons / keyboard.
    pub async fn reply_with(&self, reply: impl Into<Reply>) -> Result<()> {
        (self.inner.reply_fn)(reply.into()).await
    }

    /// Rewrite the original message (the one that carried the button
    /// the user just pressed) with `reply`'s text / keyboard. Falls
    /// back to a fresh message when editing isn't possible (no
    /// callback, platform doesn't support editing, original message
    /// gone, etc.) so callers don't need to branch.
    pub async fn edit_reply(&self, reply: impl Into<Reply>) -> Result<()> {
        let r = reply.into();
        if let Some(edit) = &self.inner.edit_fn {
            return (edit)(r).await;
        }
        (self.inner.reply_fn)(r).await
    }
}