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

FoukoApi

A Rust framework for building cross-platform bots. Write a command once, run it on Telegram, Discord and anything you plug in next.

api.fouko.xyz · fouko.xyz · Discord · Telegram


[!NOTE] FoukoApi is in its alpha series (currently 0.1.2-alpha.1). The public surface below is stable in spirit; feature-gated internals may still shift.

What is it

FoukoApi is a small, opinionated framework that lets you describe a bot once - its commands, handlers, state, linked-account UX - and run the same bot on multiple chat platforms without forking logic for each one.

No custom DSL, no macros you have to learn. Just Rust functions and a Bot builder.

Contents

Features

  • One codebase, many platforms. Telegram and Discord shipped, more adapters possible behind the same Platform trait. Your handler doesn't care which one it's running on.
  • Build a Keyboard once - Telegram gets inline keyboards, Discord gets message components. A keyboard that fits Telegram but overflows Discord's 5x5 cap is repacked there instead of rejected.
  • Embed with title / description / fields / footer / color renders as a real Discord embed and as HTML on Telegram, from the same code.
  • Pluggable storage. Any async key-value store works behind the Storage trait. Bundled: in-memory (good for tests) and SQLite (good for a self-hosted bot).
  • Account linking. Accounts::start_link / redeem_link ties Telegram and Discord identities with a 6-char code. A ready-made /link command ships with it.
  • Economy. XP, a level curve, coins, atomic transfers, cooldowns, achievements, a leaderboard. Balances follow the account link, so a user's XP is the same on both platforms.
  • I18n - a plain key/language catalogue with English fallback and {} placeholders. No macros, no build step, no .po files.
  • Push messages. Notifier sends into any chat without an incoming event - reminders, scheduled jobs. send_dm reaches a user's DMs by id, user_name resolves a display name outside any update.
  • Per-user rate limiting is on by default (tune with Bot::rate_limit), and Bot::on_flood fires a handler when the total update flow spikes - handy against spam waves.
  • Colorful startup banner: gradient ASCII art plus an aligned status table. Honors NO_COLOR and non-TTY output.
  • Secrets at rest. Secret (feature crypto) encrypts strings with AES-256-GCM, keyed off an operator passphrase.
  • GenAI client. GenClient (feature genai) speaks to any OpenAI-compatible host: chat with SSE streaming, tool calling, vision input, model discovery with capability tags, image/video/speech generation with selectable voices.
  • Telegram Mini Apps. Button::web_app opens a Mini App from a button (Discord falls back to a link), and validate_init_data (feature webapp) verifies the app's initData signature server-side.
  • Quick https tunnels. tunnel::cloudflared_quick (feature tunnel) gets a free public https URL for a local port via the cloudflared binary - a Mini App can run from a laptop behind NAT.
  • Handler quality-of-life: Ctx::typing() shows a typing indicator, reply_temporary auto-deletes after N seconds, avatar_url / banner_url / chat_info look up user and chat details cross-platform.
  • Built-in /help and /lang, one builder call each, localised per user. Group commands into sections with Bot::category.
  • Registered commands are published as Discord slash commands and Telegram's command menu automatically, so they show up in each client's autocomplete.
  • Async from the ground up, built on tokio. No unsafe, no build-time code generation, no custom DSL.
  • Every platform is a trait impl. Rolling an adapter for a niche chat service is a weekend project.

Quick start

# Cargo.toml
[dependencies]
foukoapi = "0.1.2-alpha.1"
tokio = { version = "1", features = ["full"] }
use foukoapi::{Bot, Button, Keyboard, Platform, Reply};

#[tokio::main]
async fn main() -> foukoapi::Result<()> {
    Bot::new()
        .add_platform(Platform::telegram(std::env::var("TG_TOKEN")?))
        .add_platform(Platform::discord(std::env::var("DISCORD_TOKEN")?))
        .command("/help", |ctx| async move {
            ctx.reply("hi! i work everywhere").await
        })
        .command("/menu", |ctx| async move {
            let kb = Keyboard::new()
                .row([Button::callback("roll", "roll"), Button::callback("coin", "coin")])
                .row([Button::url("website", "https://bot.fouko.xyz")]);
            ctx.reply_with(Reply::text("pick one").keyboard(kb)).await
        })
        .run()
        .await
}

That single /help command responds on every platform you added, and the Keyboard is rendered natively on each one (inline keyboard in Telegram, message components in Discord).

Mini-wiki

Cargo features

Feature Default What it does
telegram yes Adds the Telegram adapter (teloxide).
discord yes Adds the Discord adapter (serenity).
sqlite yes Enables the bundled SQLite storage backend.
crypto no Secret - AES-256-GCM at-rest encryption.
genai no GenClient - OpenAI-compatible generation client.
webapp no Telegram Mini App initData validation.
tunnel no Cloudflare quick tunnels via the cloudflared binary.
full no Turns on everything above.
foukoapi = { version = "0.1.2-alpha.1", default-features = false, features = ["telegram", "sqlite"] }

Environment variables

bootstrap_env() creates a commented .env template on the first run. Defaults:

Variable What it is
TG_TOKEN Telegram bot token from @BotFather.
DISCORD_TOKEN Discord bot token (Developer Portal -> Bot -> Reset Token).
FOUKO_DB Storage URL. sqlite:./foukobot.sqlite, memory:, or a custom scheme your own Storage understands. If unset, a SQLite file is placed next to the discovered .env (never inside target/).
RUST_LOG Standard tracing filter, e.g. info,foukoapi=debug.

Core types

Bot          - builder. Add platforms, commands, on_message hooks, defaults.
Platform     - "here's a Telegram/Discord token". Plugged into Bot::add_platform.
Ctx          - everything a handler needs: platform, user_id, text, args,
               reply / reply_with / edit_reply, callback_data, is_dm, ...
Reply        - what you send back. Reply::text(..), Reply::embed(..),
               with optional .keyboard(kb).
Embed        - rich reply card. .title / .description / .field(_inline) /
               .footer / .image / .thumbnail / .url / .color.
Keyboard     - rows of Buttons. Button::callback("label", "cb_data") or
               Button::url("label", "https://...").
Storage      - async KV store trait (get/set/del, set_nx write-if-absent,
               list_prefix). AnyStorage is a boxed version.
Accounts     - cross-platform user linking. Sits on top of Storage.
Economy      - XP, levels, coins, transfers, cooldowns, achievements,
               leaderboard. Sits on top of Accounts so balances follow
               the account link.
I18n         - key/language string catalogue with English fallback and tf(..)
               placeholder filling.
Notifier     - outbound handle: push into a chat or a user's DMs without an
               incoming event, resolve display names by id.
RateLimiter  - per-user rate limiting; on by default with a relaxed policy.

Built-in commands

One builder call each, all localised and DM-aware where it makes sense:

Bot::new()
    .with_accounts(accounts.clone())       // needed by /help (i18n), /lang, /link
    .with_default_help()                   // lists every described command
    .with_default_lang_command(["en", "ru"]) // picker with inline buttons
    .with_default_link_command()           // 6-char code flow + unlink button

Minimal bot with state

use foukoapi::{open_storage, Accounts, Bot, Embed, Platform, Reply};

#[tokio::main]
async fn main() -> foukoapi::Result<()> {
    foukoapi::bootstrap_env()?;
    let storage = open_storage()?;               // reads FOUKO_DB
    let accounts = Accounts::with_arc(storage.clone());

    Bot::new()
        .with_accounts(accounts.clone())
        .add_platform(Platform::telegram(std::env::var("TG_TOKEN")?))
        .add_platform(Platform::discord(std::env::var("DISCORD_TOKEN")?))
        .command_described("/ping", "health check", |ctx| async move {
            let em = Embed::new().title("\u{1F3D3} Pong").description("alive");
            ctx.reply_with(Reply::embed(em)).await
        })
        .with_default_help()
        .with_default_lang_command(["en", "ru"])
        .with_default_link_command()
        .run()
        .await
}

Account linking flow

┌─ Telegram ─┐                 ┌─ Discord ─┐
│ /link      │ ── 6-char code ─▶            │
│            │                 │ /link CODE │
│            │ ◀── linked ─────│            │
└────────────┘                 └────────────┘
         │                            │
         └──── pick primary (once) ───┘
                     │
              primary keeps XP/coins/settings
              the other side stays clean

         ┌─ later, anywhere ─┐
         │ /link -> Unlink    │
         │                   │
         │ primary keeps its │
         │ profile, other    │
         │ side -> fresh      │
         └───────────────────┘
  • Accounts::start_link issues a 6-char code valid for 5 minutes.
  • Accounts::redeem_link on the other side ties them together.
  • The built-in /link command (Bot::with_default_link_command) adds an inline-button primary picker that locks in the choice exactly once. After that, the only way to pick a different primary is to /link -> Unlink and start over.
  • After Accounts::unlink, the primary-ident keeps its data (XP/coins/settings/lang); the ex-partner platform falls back to its own ident and therefore starts from a clean slate.

Economy

Economy wraps the same Accounts you already have, so a user who linked two platforms shares one wallet:

use foukoapi::{Accounts, Economy, Metric, PlatformKind};

async fn demo(accounts: Accounts) -> foukoapi::Result<()> {
    let econ = Economy::new(accounts);
    let d = PlatformKind::Discord;

    econ.add_xp(d, "42", 30).await?;          // -> XpGain { xp, coins_minted }
    let w = econ.wallet(d, "42").await;       // { xp, coins, level }
    econ.add_coins(d, "42", 100).await?;
    econ.transfer((d, "42"), (d, "7"), 25).await?;   // send coins, atomic

    let top = econ.leaderboard(Metric::Xp, 10).await; // ranked players
    let me  = econ.rank_of(d, "42", Metric::Xp).await; // your position
    Ok(())
}

Coins are minted at one per XP_PER_COIN (10) XP; add_xp returns an XpGain so you can announce freshly minted coins. Balance updates are serialised, so parallel grants don't lose XP and concurrent transfers can't overdraw a wallet. Cooldowns (cooldown_remaining / touch_cooldown), achievements (grant_achievement / achievements) and cosmetic slots (title, color) round it out for /daily, /gamble and a profile card.

Translations (I18n)

use foukoapi::I18n;

let i18n = I18n::new()
    .add("hi", &[("en", "Hello"), ("ru", "Привет"), ("de", "Hallo")])
    .add("bye", &[("en", "Bye, {}!"), ("ru", "Пока, {}!")]);

assert_eq!(i18n.t("ru", "hi"), "Привет");
assert_eq!(i18n.t("fr", "hi"), "Hello");           // English fallback
assert_eq!(i18n.tf("en", "bye", &["Sam"]), "Bye, Sam!");

Missing translations fall back to English; a missing key falls back to the key itself, so nothing ever renders blank.

Utility helpers (foukoapi::util)

Small, platform-agnostic helpers that you'll reach for in pretty much every bot:

use foukoapi::util::{capitalize, progress_bar, urlencode};

assert_eq!(capitalize("telegram"), "Telegram");
assert_eq!(progress_bar(3, 10, 10), "▰▰▰▱▱▱▱▱▱▱");
assert_eq!(urlencode("hello world"), "hello%20world");
  • capitalize(&str) -> String - uppercases the first char, UTF-8 safe.
  • progress_bar(done, total, width) -> String - / bar of the given width.
  • urlencode(&str) -> String - minimal percent-encoder for query-string args.
  • split_chunks(&str, limit) -> Vec<String> - split long text on line/word boundaries; the adapters use it to dodge platform length limits.

Status

Platform Status
Telegram ✅ Working
Discord ✅ Working
Feature Status
Command router ✅ Working
Keyboards / inline buttons ✅ Working
Embeds (Discord + HTML TG) ✅ Working
Storage (memory + sqlite) ✅ Working
Account linking + primary ✅ Working
Economy (XP, coins, achievements, leaderboard) ✅ Working
Translations (I18n) ✅ Working
Built-in /help, /lang, /link ✅ Working
Push messages (Notifier) ✅ Working
Rate limiting + flood watch ✅ Working
Startup banner ✅ Working
Secrets at rest (crypto) ✅ Working
GenAI client (genai) ✅ Working
Mini App validation (webapp) ✅ Working
Quick tunnels (tunnel) ✅ Working
Util helpers ✅ Working
Argument parsing helpers ⏳ Planned
Middleware chain ⏳ Planned

Examples

The examples/ directory is where ready-to-run sample bots live. A reference bot that uses every feature is also published as FoukoBot - its source is a good place to watch the API take shape.

# run the quickstart example (needs TG_TOKEN / DISCORD_TOKEN)
cargo run --example quickstart

# streaming GenAI showcase (needs TG_TOKEN and an OpenAI-compatible host)
cargo run --example genai --features "genai telegram"

MSRV

Rust 1.75 or newer (pinned via rust-version in Cargo.toml).

Contributing

Issues and PRs are welcome. The repo follows the usual "fork, branch, PR" flow. Be kind in the tracker, keep diffs small, and run cargo fmt + cargo clippy --all-targets before opening a PR.

License

Licensed under either of MIT or Apache-2.0, at your option. Unless you explicitly state otherwise, any contribution you submit for inclusion is dual-licensed the same way, without additional terms.


Part of the Fouko family.