foukoapi 0.1.2-alpha.1

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
<div align="center">

<img src="https://fouko.xyz/assets/brand/LogoGold.png" alt="FoukoApi" width="96" height="96">

# FoukoApi

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

<a href="https://crates.io/crates/foukoapi"><img alt="crates.io" src="https://img.shields.io/crates/v/foukoapi?style=for-the-badge&color=fbbf24&labelColor=0a0a0f"></a>
<a href="https://docs.rs/foukoapi"><img alt="docs.rs" src="https://img.shields.io/docsrs/foukoapi?style=for-the-badge&color=f97316&labelColor=0a0a0f"></a>
<a href="https://github.com/FoukoDev/FoukoApi/actions"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/FoukoDev/FoukoApi/ci.yml?style=for-the-badge&labelColor=0a0a0f"></a>
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-MIT-6366f1?style=for-the-badge&labelColor=0a0a0f"></a>

<a href="https://api.fouko.xyz">api.fouko.xyz</a> · <a href="https://fouko.xyz">fouko.xyz</a> · <a href="https://discord.gg/rx9nXt735R">Discord</a> · <a href="https://t.me/foukoo">Telegram</a>

</div>

---

> [!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

- [Quick start]#quick-start
- [Mini-wiki]#mini-wiki
  - [Cargo features]#cargo-features
  - [Environment variables]#environment-variables
  - [Core types]#core-types
  - [Built-in commands]#built-in-commands
  - [Minimal bot with state]#minimal-bot-with-state
  - [Account linking flow]#account-linking-flow
  - [Economy]#economy
  - [Translations (`I18n`)]#translations-i18n
  - [Utility helpers (`foukoapi::util`)]#utility-helpers-foukoapiutil
- [Status]#status
- [Examples]#examples
- [MSRV, contributing, license]#msrv

## 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

```toml
# Cargo.toml
[dependencies]
foukoapi = "0.1.2-alpha.1"
tokio = { version = "1", features = ["full"] }
```

```rust
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.                             |

```toml
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]https://t.me/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

```text
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:

```rust
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

```rust
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:

```rust
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`)

```rust
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:

```rust
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/`](examples) directory is where ready-to-run sample bots live. A reference bot that uses every feature is also published as [FoukoBot](https://github.com/FoukoDev/Fouko) - its source is a good place to watch the API take shape.

```bash
# 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"
```

<a id="msrv"></a>
## 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

MIT - see [LICENSE](LICENSE).

---

<sub>Part of the <a href="https://fouko.xyz">Fouko</a> family.</sub>