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
//! Cross-platform buttons, keyboards and rich "embed" replies.
//!
//! Every supported platform has a notion of an "attach me to a message"
//! button: Telegram has inline keyboards, Discord has message components,
//! Matrix has plain message reactions / buttons in custom event types.
//!
//! FoukoApi gives you one type ([`Keyboard`]) that every adapter converts
//! into the platform's native equivalent.
//!
//! For pretty output there's also [`Embed`] - a platform-agnostic
//! description of "title + body + some labelled fields + footer". On
//! Discord it turns into a real embed. Elsewhere adapters render it as
//! nicely-formatted text (HTML on Telegram, Markdown/HTML on Matrix).

/// One button inside a [`Keyboard`].
#[derive(Debug, Clone)]
pub struct Button {
    pub(crate) label: String,
    pub(crate) kind: ButtonKind,
}

#[derive(Debug, Clone)]
pub(crate) enum ButtonKind {
    /// Clicking sends a callback with this id back to the bot.
    Callback(String),
    /// Clicking opens this URL in the user's browser.
    Url(String),
}

impl Button {
    /// Callback button. `id` is what comes back when the user presses it.
    pub fn callback(label: impl Into<String>, id: impl Into<String>) -> Self {
        Self {
            label: label.into(),
            kind: ButtonKind::Callback(id.into()),
        }
    }

    /// Link button. The user's client opens the URL when pressed.
    pub fn url(label: impl Into<String>, url: impl Into<String>) -> Self {
        Self {
            label: label.into(),
            kind: ButtonKind::Url(url.into()),
        }
    }

    /// The label shown on the button.
    pub fn label(&self) -> &str {
        &self.label
    }

    /// The callback id, if this is a callback button.
    pub fn callback_id(&self) -> Option<&str> {
        match &self.kind {
            ButtonKind::Callback(id) => Some(id.as_str()),
            _ => None,
        }
    }

    /// The target url, if this is a url button.
    pub fn url_target(&self) -> Option<&str> {
        match &self.kind {
            ButtonKind::Url(u) => Some(u.as_str()),
            _ => None,
        }
    }
}

/// A grid of [`Button`]s attached to a message.
///
/// Rows are vecs of buttons; the outer vec is the list of rows.
#[derive(Debug, Clone, Default)]
pub struct Keyboard {
    pub(crate) rows: Vec<Vec<Button>>,
}

impl Keyboard {
    /// Start a new, empty keyboard.
    pub fn new() -> Self {
        Self { rows: Vec::new() }
    }

    /// Append a row of buttons.
    pub fn row(mut self, buttons: impl IntoIterator<Item = Button>) -> Self {
        self.rows.push(buttons.into_iter().collect());
        self
    }

    /// Access the stored rows (useful for adapters).
    pub fn rows(&self) -> &[Vec<Button>] {
        &self.rows
    }

    /// Total number of buttons on the keyboard.
    pub fn len(&self) -> usize {
        self.rows.iter().map(|r| r.len()).sum()
    }

    /// `true` if there are no buttons at all.
    pub fn is_empty(&self) -> bool {
        self.rows.iter().all(|r| r.is_empty())
    }
}

/// One labelled row inside an [`Embed`].
#[derive(Debug, Clone)]
pub struct EmbedField {
    pub(crate) name: String,
    pub(crate) value: String,
    /// When `true`, adapters that support it (Discord) try to lay the
    /// field out side-by-side with its neighbours.
    pub(crate) inline: bool,
}

impl EmbedField {
    /// New field with the given name/value. Not inline by default.
    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            value: value.into(),
            inline: false,
        }
    }

    /// Mark this field as inline (Discord side-by-side layout).
    pub fn inline(mut self, yes: bool) -> Self {
        self.inline = yes;
        self
    }

    /// The field's label.
    pub fn name(&self) -> &str {
        &self.name
    }

    /// The field's body text.
    pub fn value(&self) -> &str {
        &self.value
    }

    /// Whether the adapter should try to render this field inline.
    pub fn is_inline(&self) -> bool {
        self.inline
    }
}

/// A platform-agnostic pretty-printed block.
///
/// Every field is optional. Adapters render whichever bits are present:
/// Discord builds a real embed, Telegram uses HTML, Matrix uses an HTML
/// body with a plain-text fallback. Keep `title` short and `description`
/// meaningful; the rest is sprinkles.
#[derive(Debug, Clone, Default)]
pub struct Embed {
    pub(crate) title: Option<String>,
    pub(crate) description: Option<String>,
    pub(crate) fields: Vec<EmbedField>,
    pub(crate) footer: Option<String>,
    /// Accent colour as a 0xRRGGBB integer. Discord-only, ignored
    /// elsewhere.
    pub(crate) color: Option<u32>,
    pub(crate) url: Option<String>,
    /// Big picture rendered under the description. Discord shows it as
    /// part of the embed; Telegram/Matrix append it as a plain URL the
    /// client link-previews.
    pub(crate) image_url: Option<String>,
    /// Small thumbnail in the top-right of the embed. Discord only.
    pub(crate) thumbnail_url: Option<String>,
}

impl Embed {
    /// Start a new, empty embed.
    pub fn new() -> Self {
        Self::default()
    }

    /// Title (top of the embed).
    pub fn title(mut self, t: impl Into<String>) -> Self {
        self.title = Some(t.into());
        self
    }

    /// Main body text.
    pub fn description(mut self, d: impl Into<String>) -> Self {
        self.description = Some(d.into());
        self
    }

    /// Add one labelled field.
    pub fn field(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
        self.fields.push(EmbedField::new(name, value));
        self
    }

    /// Add one labelled field laid out inline (side by side on Discord).
    pub fn field_inline(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
        self.fields.push(EmbedField::new(name, value).inline(true));
        self
    }

    /// Append an already-built field.
    pub fn push_field(mut self, field: EmbedField) -> Self {
        self.fields.push(field);
        self
    }

    /// Footer text (small line at the bottom).
    pub fn footer(mut self, f: impl Into<String>) -> Self {
        self.footer = Some(f.into());
        self
    }

    /// Accent colour as `0xRRGGBB`. Only Discord actually paints it.
    pub fn color(mut self, rgb: u32) -> Self {
        self.color = Some(rgb & 0x00FF_FFFF);
        self
    }

    /// Hyperlink the title to a URL. Discord-only, silently ignored
    /// elsewhere.
    pub fn url(mut self, u: impl Into<String>) -> Self {
        self.url = Some(u.into());
        self
    }

    /// Big image under the description. Shown as a real embed image on
    /// Discord; Telegram/Matrix tack it on as a preview-friendly URL.
    pub fn image(mut self, url: impl Into<String>) -> Self {
        self.image_url = Some(url.into());
        self
    }

    /// Small thumbnail in the corner. Discord-only (elsewhere ignored).
    pub fn thumbnail(mut self, url: impl Into<String>) -> Self {
        self.thumbnail_url = Some(url.into());
        self
    }

    /// Title, if any.
    pub fn get_title(&self) -> Option<&str> {
        self.title.as_deref()
    }

    /// Description, if any.
    pub fn get_description(&self) -> Option<&str> {
        self.description.as_deref()
    }

    /// All fields in the order they were added.
    pub fn get_fields(&self) -> &[EmbedField] {
        &self.fields
    }

    /// Footer, if any.
    pub fn get_footer(&self) -> Option<&str> {
        self.footer.as_deref()
    }

    /// Accent colour, if any.
    pub fn get_color(&self) -> Option<u32> {
        self.color
    }

    /// Title URL, if any.
    pub fn get_url(&self) -> Option<&str> {
        self.url.as_deref()
    }

    /// Big-image URL, if any.
    pub fn get_image(&self) -> Option<&str> {
        self.image_url.as_deref()
    }

    /// Thumbnail URL, if any.
    pub fn get_thumbnail(&self) -> Option<&str> {
        self.thumbnail_url.as_deref()
    }

    /// `true` when every slot is empty.
    pub fn is_empty(&self) -> bool {
        self.title.is_none()
            && self.description.is_none()
            && self.fields.is_empty()
            && self.footer.is_none()
    }
}

/// A reply you want to send from a handler: optional text, optional
/// embed, optional keyboard.
///
/// Returned by convenience builders; `Ctx::reply_with` takes one.
#[derive(Debug, Clone, Default)]
pub struct Reply {
    pub(crate) text: String,
    pub(crate) embed: Option<Embed>,
    pub(crate) keyboard: Option<Keyboard>,
}

impl Reply {
    /// Start a reply with just text.
    pub fn text(text: impl Into<String>) -> Self {
        Self {
            text: text.into(),
            embed: None,
            keyboard: None,
        }
    }

    /// Start a reply with just an [`Embed`].
    pub fn embed(embed: Embed) -> Self {
        Self {
            text: String::new(),
            embed: Some(embed),
            keyboard: None,
        }
    }

    /// Replace (or add) the embed attached to this reply.
    pub fn with_embed(mut self, embed: Embed) -> Self {
        self.embed = Some(embed);
        self
    }

    /// Attach a keyboard.
    pub fn keyboard(mut self, kb: Keyboard) -> Self {
        self.keyboard = Some(kb);
        self
    }

    /// Replace the body text.
    pub fn with_text(mut self, text: impl Into<String>) -> Self {
        self.text = text.into();
        self
    }

    /// Body text.
    pub fn get_text(&self) -> &str {
        &self.text
    }

    /// Attached embed, if any.
    pub fn get_embed(&self) -> Option<&Embed> {
        self.embed.as_ref()
    }

    /// Attached keyboard, if any.
    pub fn get_keyboard(&self) -> Option<&Keyboard> {
        self.keyboard.as_ref()
    }
}

impl From<&str> for Reply {
    fn from(s: &str) -> Self {
        Self::text(s)
    }
}
impl From<String> for Reply {
    fn from(s: String) -> Self {
        Self::text(s)
    }
}
impl From<Embed> for Reply {
    fn from(e: Embed) -> Self {
        Self::embed(e)
    }
}