foukoapi 0.1.1-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
//! 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>;

/// 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>>>,
}

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())),
        }
    }

    /// 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`. Same lifecycle as
    /// [`Notifier::register`]: adapters call this once they have a live
    /// client, replacing an existing entry is fine.
    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`] if that platform has no DM sender
    /// registered (yet), so background jobs can decide whether to retry.
    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"
            ))),
        }
    }
}