foukoapi 0.1.2-alpha.2

Cross-platform bot framework in Rust: one codebase, many platforms. Shared accounts, embeds, keyboards, economy, i18n and pluggable storage; Telegram and Discord adapters included.
Documentation
//! Outbound messaging: send a message into a chat without waiting for the
//! user to say something first.
//!
//! Everything else in FoukoApi is reactive - a handler answers an incoming
//! update. A [`Notifier`] is the one exception: it lets background work
//! (reminders, scheduled announcements, cron-style jobs) push a message
//! into a chat on its own.
//!
//! ## How it fits together
//!
//! You build a [`Notifier`] before the bot starts and keep a clone. Each
//! adapter, once it's connected, registers a *sender* for its platform.
//! From then on [`Notifier::send`] routes a [`Reply`] to the matching
//! adapter, which delivers it with the same rendering a normal reply uses.
//!
//! ```no_run
//! use foukoapi::{Bot, Notifier, PlatformKind, Reply};
//!
//! # async fn run() -> foukoapi::Result<()> {
//! let notifier = Notifier::new();
//! let bg = notifier.clone();
//!
//! // Somewhere in a background task, later:
//! bg.send(PlatformKind::Telegram, "123456", Reply::text("scheduled ping"))
//!     .await?;
//!
//! Bot::new().with_notifier(notifier) /* .add_platform(..) */ .run().await
//! # }
//! ```
//!
//! Sending before the adapter has connected (or to a platform the bot
//! doesn't run) returns [`Error::Other`], so callers can retry or drop it.

use crate::{keyboard::Reply, platform::PlatformKind, Error, Result};
use futures::future::BoxFuture;
use std::{collections::HashMap, sync::Arc};
use tokio::sync::RwLock;

/// A per-platform delivery function installed by an adapter. Takes a chat
/// id and a reply, sends it, resolves when done.
pub(crate) type SendFn =
    Arc<dyn Fn(String, Reply) -> BoxFuture<'static, Result<()>> + Send + Sync + 'static>;

/// Like [`SendFn`] but keyed by a *user* id: the adapter resolves (or
/// creates) the user's DM channel first, then delivers into it.
pub(crate) type DmSendFn =
    Arc<dyn Fn(String, Reply) -> BoxFuture<'static, Result<()>> + Send + Sync + 'static>;

/// A per-platform user lookup installed by an adapter. Takes a user id,
/// resolves to the user's display name, or `None` when the platform
/// doesn't know that user.
pub(crate) type UserLookupFn =
    Arc<dyn Fn(String) -> BoxFuture<'static, Result<Option<String>>> + Send + Sync + 'static>;

/// Republishes the Mini App menu button with a new URL. Installed by the
/// Telegram adapter; used when a tunnel restarts on a fresh URL.
pub(crate) type MenuAppFn =
    Arc<dyn Fn(String, String) -> BoxFuture<'static, Result<()>> + Send + Sync + 'static>;

/// A cloneable handle for pushing messages into chats out of band.
///
/// Clones share the same routing table, so the clone you hand to a
/// background task sees adapters as they come online.
#[derive(Clone, Default)]
pub struct Notifier {
    senders: Arc<RwLock<HashMap<PlatformKind, SendFn>>>,
    dm_senders: Arc<RwLock<HashMap<PlatformKind, DmSendFn>>>,
    user_lookups: Arc<RwLock<HashMap<PlatformKind, UserLookupFn>>>,
    menu_app: Arc<RwLock<Option<MenuAppFn>>>,
}

impl Notifier {
    /// A fresh notifier with no adapters registered yet.
    pub fn new() -> Self {
        Self {
            senders: Arc::new(RwLock::new(HashMap::new())),
            dm_senders: Arc::new(RwLock::new(HashMap::new())),
            user_lookups: Arc::new(RwLock::new(HashMap::new())),
            menu_app: Arc::new(RwLock::new(None)),
        }
    }

    /// Install the menu-button publisher (Telegram adapter). Lets
    /// [`Notifier::set_menu_web_app`] update the Mini App button at
    /// runtime, e.g. when a tunnel restarts on a fresh URL.
    pub(crate) async fn register_menu_app(&self, publish: MenuAppFn) {
        *self.menu_app.write().await = Some(publish);
    }

    /// Update the Telegram Mini App menu button (label + url) at runtime.
    /// Fails with [`Error::Other`] until the Telegram adapter is up.
    pub async fn set_menu_web_app(
        &self,
        label: impl Into<String>,
        url: impl Into<String>,
    ) -> Result<()> {
        let publish = { self.menu_app.read().await.clone() };
        match publish {
            Some(f) => f(label.into(), url.into()).await,
            None => Err(Error::Other(
                "no live telegram adapter to publish the menu button through".to_owned(),
            )),
        }
    }

    /// Install the delivery function for `platform`. Adapters call this
    /// once they have a live client. Replacing an existing entry is fine
    /// (e.g. after a reconnect).
    pub(crate) async fn register(&self, platform: PlatformKind, send: SendFn) {
        self.senders.write().await.insert(platform, send);
    }

    /// Install the DM delivery function for `platform`. Same lifecycle as
    /// [`Notifier::register`], but the function takes a user id and is
    /// responsible for reaching that user's private chat.
    pub(crate) async fn register_dm(&self, platform: PlatformKind, send: DmSendFn) {
        self.dm_senders.write().await.insert(platform, send);
    }

    /// Install the user lookup for `platform` - registered by adapters
    /// alongside their senders, and replaceable after a reconnect.
    pub(crate) async fn register_user_lookup(&self, platform: PlatformKind, lookup: UserLookupFn) {
        self.user_lookups.write().await.insert(platform, lookup);
    }

    /// `true` when `platform` has a live sender registered.
    pub async fn is_ready(&self, platform: PlatformKind) -> bool {
        self.senders.read().await.contains_key(&platform)
    }

    /// `true` when `platform` has a live DM sender registered.
    pub async fn is_dm_ready(&self, platform: PlatformKind) -> bool {
        self.dm_senders.read().await.contains_key(&platform)
    }

    /// Send a plain-text message to `chat_id` on `platform`.
    pub async fn send_text(
        &self,
        platform: PlatformKind,
        chat_id: impl Into<String>,
        text: impl Into<String>,
    ) -> Result<()> {
        self.send(platform, chat_id, Reply::text(text)).await
    }

    /// Send a fully built [`Reply`] to `chat_id` on `platform`.
    ///
    /// Fails with [`Error::Other`] if that platform has no adapter
    /// connected (yet), so background jobs can decide whether to retry.
    pub async fn send(
        &self,
        platform: PlatformKind,
        chat_id: impl Into<String>,
        reply: impl Into<Reply>,
    ) -> Result<()> {
        let sender = {
            let map = self.senders.read().await;
            map.get(&platform).cloned()
        };
        match sender {
            Some(send) => send(chat_id.into(), reply.into()).await,
            None => Err(Error::Other(format!(
                "no live {platform} adapter to send through"
            ))),
        }
    }

    /// Send a [`Reply`] into `user_id`'s direct messages on `platform`.
    ///
    /// Unlike [`Notifier::send`] this takes a *user* id, not a chat id.
    /// On Telegram the two coincide for private chats; on Discord the
    /// adapter first opens (or reuses) a DM channel with the user.
    ///
    /// ```no_run
    /// use foukoapi::{Notifier, PlatformKind, Reply};
    ///
    /// # async fn run(notifier: Notifier) -> foukoapi::Result<()> {
    /// notifier
    ///     .send_dm(PlatformKind::Discord, "560024252393455645", Reply::text("psst"))
    ///     .await
    /// # }
    /// ```
    ///
    /// Fails with [`Error::Other`] while that platform has no DM sender
    /// yet - either it's still connecting or DMs aren't supported there.
    pub async fn send_dm(
        &self,
        platform: PlatformKind,
        user_id: impl Into<String>,
        reply: impl Into<Reply>,
    ) -> Result<()> {
        let sender = {
            let map = self.dm_senders.read().await;
            map.get(&platform).cloned()
        };
        match sender {
            Some(send) => send(user_id.into(), reply.into()).await,
            None => Err(Error::Other(format!(
                "no live {platform} adapter to send DMs through"
            ))),
        }
    }

    /// Look up a user's display name by id, outside any update.
    ///
    /// Useful for greeting the operator at startup ("running as owner
    /// Ivan Petrov (@ivan)") or labelling ids in logs and admin panels.
    ///
    /// Returns `Ok(None)` when the platform doesn't know the user. On
    /// Telegram the lookup goes through `getChat`, which only succeeds
    /// after the user has messaged the bot at least once; until then it
    /// resolves to `Ok(None)`. On Discord any valid user id resolves.
    ///
    /// Fails with [`Error::Other`] if that platform has no lookup
    /// registered (yet), and with a platform error on transport failure.
    pub async fn user_name(&self, platform: PlatformKind, user_id: &str) -> Result<Option<String>> {
        let lookup = {
            let map = self.user_lookups.read().await;
            map.get(&platform).cloned()
        };
        match lookup {
            Some(lookup) => lookup(user_id.to_string()).await,
            None => Err(Error::Other(format!(
                "no live {platform} adapter to look up users through"
            ))),
        }
    }
}