--- title: Oracle emoji: πŸ’Έ colorFrom: green colorTo: indigo sdk: gradio sdk_version: 5.50.0 app_file: app.py pinned: false short_description: Expense tracking and budget coaching over Telegram --- # coco-finbot > Deployed as the **Oracle** Space on `sdk: gradio`. > > The bot is FastAPI; Gradio is only there to satisfy the platform. Since > mid-2026, Docker Spaces require PRO, and the one free hardware tier for > personal accounts β€” ZeroGPU β€” refuses any SDK but Gradio. So `app.py` builds > the FastAPI app, mounts a one-page Gradio UI at `/` via > `gr.mount_gradio_app`, and serves both. No GPU is used: inference goes out > over HTTP to HF Inference Providers. > > The `Dockerfile` is still current and is what you'd use to self-host. A personal finance bot that lives in Telegram and runs as a Hugging Face Docker Space. Tell it what you spent, send it receipts and bank statements, and ask it what your money is doing. ``` you β–Έ coffee 250 bot β–Έ βœ… Logged β‚Ή250.00 β€” coffee Food & Dining Β· 2026-08-15 you β–Έ [photo of a restaurant bill] bot β–Έ βœ… Logged β‚Ή1,840.00 β€” Toit Brewpub Food & Dining Β· 2026-08-14 you β–Έ [statement.pdf] bot β–Έ Found 47 transactions in that PDF Β· β‚Ή68,204.00 3 already logged (skipped) These were read by a model and not saved yet. /confirm to save Β· /cancel to discard ``` --- ## Read this before you deploy **Space disk is ephemeral.** Hugging Face wipes it on every restart, sleep and rebuild. If you do not set `HF_DATASET_REPO`, your entire ledger disappears the first time the Space sleeps β€” silently, because nothing errors. The bot mirrors SQLite to a **private** dataset repo on a timer and restores it on boot. `/status` tells you which mode you are in. **Make the Space private.** This is your spending history. A public Space exposes the container, its logs and its `/health` endpoint to anyone. **Decide who can use it.** `ALLOWED_TELEGRAM_USER_IDS` starts empty, which authorises nobody β€” a forgotten allowlist stays closed rather than silently opening to the world. Set `OPEN_ACCESS=true` to let anyone message the bot. Ledgers are keyed by Telegram user id, so open access makes the bot **multi-tenant**: each person gets their own private ledger and nobody can see yours. What open access does expose is your **wallet** β€” every stranger's receipt and statement is read using your `HF_TOKEN`. `UPLOADS_PER_USER_PER_DAY` (default 20) caps that; typed expenses are parsed locally and never metered. Note also that running an open bot makes you the custodian of other people's financial data in your dataset repo. --- ## Deploy **1. Create the bot.** Message [@BotFather](https://t.me/BotFather) β†’ `/newbot` β†’ keep the token. **2. Push the Space and dataset.** `deploy.sh` creates both (private, Docker SDK) and uploads the source. It takes the owner from your logged-in account: ```bash ./.venv/bin/hf auth login && ./deploy.sh ``` ```bash ./deploy.sh --dry-run # show the target repos, create nothing ./deploy.sh Uniphy # deploy under an org instead of your account ``` > Use this rather than `git push`. This directory lives inside the larger UniPhy > repo, so a `git push` from here would send that entire tree β€” unrelated repos > and any secrets they hold β€” to a public-by-default host. `deploy.sh` uploads > only this folder, minus `.venv`, `.env`, caches and any local database. **3. Set secrets** under Space Settings β†’ Secrets (use the UI, not the CLI, so they stay out of your shell history): | Secret | Required | What it does | | --- | --- | --- | | `TELEGRAM_BOT_TOKEN` | βœ… | From BotFather | | `TELEGRAM_WEBHOOK_SECRET` | βœ… | Any random string; rejects forged webhook calls | | `HF_DATASET_REPO` | βœ… | `/finbot-data` β€” **without this your data is lost on restart** | | `HF_TOKEN` | βœ… | **Write**-scoped token. Needed to mirror the ledger; also used for remote inference if you pick that backend | | `ALLOWED_TELEGRAM_USERNAMES` | βœ… | Your Telegram @handle (comma-separated for more). Not needed if `OPEN_ACCESS=true` | | `ALLOWED_TELEGRAM_USER_IDS` | | Numeric IDs β€” the only option for users with no @handle set | | `PUBLIC_BASE_URL` | | Origin for dashboard links. Derived from `SPACE_HOST` on a Space | | `DASHBOARD_LINK_MINUTES` | | Sign-in link lifetime. Default `30` | | `OPEN_ACCESS` | | `true` lets anyone use the bot, each with their own ledger. Default `false` | | `UPLOADS_PER_USER_PER_DAY` | | Caps model-backed uploads per user. Default `20`, `0` disables | | `LLM_BACKEND` | | `auto` (default), `local`, `remote`, `off`. See below | | `LOCAL_LLM_MODEL` / `LOCAL_VISION_MODEL` | | Override the on-GPU models | | `DEFAULT_CURRENCY` | | ISO code for bare amounts. Default `INR` | | `TIMEZONE` | | Decides what "today" means. Default `Asia/Kolkata` | A read-only token is not enough: `CommitScheduler` writes the ledger with it, and a read token means your data silently never persists. Generate a webhook secret with: ```bash python3 -c "import secrets; print(secrets.token_urlsafe(32))" ``` **4. Restart the Space** so it picks up the secrets. On boot it reads `SPACE_HOST` and registers its own Telegram webhook β€” nothing else to wire up. **5. Message the bot.** Don't know your Telegram ID? Send it anything: it replies with the ID to paste into `ALLOWED_TELEGRAM_USER_IDS`. Then send `/status` and confirm storage reads *mirroring*, not *local only*. --- ## Using it Anything with a number in it is logged. No command needed. ``` coffee 250 β‚Ή1,240 groceries at bigbasket 45.50 lunch yesterday 320 uber #transport ← #tag overrides the category guess €40 dinner 12 aug ``` Send a **photo** of a receipt and a vision model reads the total. Send a **PDF/CSV/XLSX** statement and it extracts the transactions, drops any already in your ledger, and shows you the total before saving β€” nothing model-extracted is written until you `/confirm`. | Command | | | --- | --- | | `/today` `/week` `/month` | Spending summaries with a category breakdown | | `/report ` | `today`, `week`, `month`, `last_month`, `30d`, `year`, `all` | | `/list [n]` | Recent transactions | | `/recurring` | Subscriptions and repeat charges, with the annual cost | | `/advice [question]` | Observations grounded in your own numbers | | `/budget ` | Set a monthly cap | | `/budgets` | How you're tracking | | `/categories` | Category names for tags and budgets | | `/undo` | Remove the last entry, or the last import wholesale | | `/export` | Everything as CSV | | `/status` | Config and storage health | --- ## How it works ``` Telegram ──webhook──▢ FastAPI (:7860) β”‚ β”œβ”€ registry ──▢ features: expenses β”‚ budgets β”‚ coaching β”‚ core β”‚ β”‚ β”‚ β”œβ”€ parser deterministic, free, first β”‚ β”œβ”€ extract CSV Β· XLSX Β· PDF β”‚ └─ llm ──▢ router.huggingface.co/v1 β”‚ └─ store: SQLite ──CommitScheduler──▢ private HF dataset ``` **Deterministic first, model second.** The regex parser handles `coffee 250` instantly and identically every time. Inference is spent only where judgement is genuinely needed: reading a receipt, or working out which column of an unfamiliar bank export holds the amount. If inference is unavailable, text logging and every summary still work. **Two interchangeable inference backends.** `local` runs a small instruct model (Qwen2.5-3B, plus a 3B VL model for receipts) on the Space's own borrowed A10G; `remote` calls HF Inference Providers. Same interface, chosen by `LLM_BACKEND`, and `auto` picks local whenever a CUDA device is present. Local is the default on a Space because it costs nothing per call β€” which is what makes `OPEN_ACCESS` affordable, since otherwise every stranger's receipt would spend your inference credits. **Money is never a float.** Amounts are integer minor units (paise, cents, fils) plus an ISO 4217 code. Currencies are never summed together β€” a multi-currency month reports one total per currency rather than a fictional combined figure. **Statements are staged, not trusted.** A model that misreads a column would otherwise quietly poison months of history, so imports become a pending batch you confirm, tagged with a `batch_id` so `/undo` can reverse the whole thing. **Inference is provider-agnostic.** `LLM_BASE_URL` is an OpenAI-compatible endpoint. It defaults to HF's router β€” one token, many providers β€” but points just as happily at Ollama, llama.cpp or OpenAI. --- ## Adding a feature The registry is the extension point. A feature declares commands and optionally claims plain messages or uploaded files; the first one to return a `Reply` wins. Nothing else in the codebase changes. ```python from finbot.features.base import BotContext, CommandSpec, Reply, SimpleFeature class RemindersFeature(SimpleFeature): name = "reminders" description = "⏰ Reminders" def commands(self): return (CommandSpec("remind", "Set a reminder", " "),) def cmd_remind(self, ctx: BotContext) -> Reply: return Reply(f"Noted: {ctx.args}") ``` Add it to `default_features()` in `finbot/features/__init__.py`. `/help` and Telegram's command menu build themselves from what is registered, and duplicate commands raise at startup rather than shadowing silently. --- ## Development ```bash python3.11 -m venv .venv && ./.venv/bin/pip install -r requirements.txt pytest cp .env.example .env # fill in what you need ./.venv/bin/python -m pytest tests/ -q ./.venv/bin/uvicorn app:app --reload --port 7860 ``` To test against real Telegram locally, expose port 7860 with a tunnel and register the webhook: ```bash curl -X POST localhost:7860/admin/setup -H "X-Admin-Secret: $TELEGRAM_WEBHOOK_SECRET" -H 'Content-Type: application/json' -d '{"url":"https:///telegram/webhook"}' ``` 268 tests cover the pure logic β€” money arithmetic, parsing, categorisation, analytics, the registry contract β€” plus an end-to-end pass over the webhook with a stubbed Telegram transport. None of them touch the network. --- ## Known limits - **Credits are not stored.** Statement imports keep debits only; incoming money is counted and reported but not written, because mixing it into the same table would corrupt every "total spent" figure. Income tracking is a real feature, not a small one. - **Scanned PDFs** have no text layer. The bot detects this and tells you to send it as a photo instead. - **Legacy `.xls`** is not supported β€” re-save as `.xlsx` or CSV. - **20 MB** is Telegram's own cap on bot file downloads. - **Pending imports live in memory**, so a Space restart between the preview and `/confirm` drops them. Re-send the file. - **Recurring detection is lexical.** It unifies `NETFLIX.COM 447192` with `Netflix`, but not `AMZN Mktp` with `Amazon`. - **Advice is budgeting only.** It comments on your logged spending and is explicitly prevented from recommending investments or giving tax advice. - **Free Spaces sleep** after 48h idle, and the first request after that pays a cold start plus a model load. Telegram retries, so the message lands β€” it is just slow. - **A 3B model is not a frontier model.** It is good enough for "which column is the amount" and short coaching; expect worse statement extraction than the remote backend. Switch with `LLM_BACKEND=remote` if you would rather spend credits for quality. - **ZeroGPU has a daily GPU-second quota** on free accounts. Heavy statement days can exhaust it; typed expenses keep working regardless. - **`ssr_mode=False` is load-bearing.** Spaces sets `GRADIO_SSR_MODE`, which switches the frontend to a Node-rendered SvelteKit build served from `/_app/immutable/*`. Because this app calls `demo.launch()` itself rather than going through Gradio's standard Space entrypoint, that SSR server never starts, so every CSS/JS asset 404s. The symptom is nasty: the page still returns HTTP 200 with a server-rendered shell, so it looks "up" while no JavaScript ever runs β€” meaning `demo.load` never fires and the dashboard silently ignores its sign-in token. Do not remove it.