File size: 12,496 Bytes
9b43357
 
bfd6783
 
 
ad12eba
 
 
9b43357
bfd6783
9b43357
 
bfd6783
 
ad12eba
 
 
 
 
 
 
 
 
 
bfd6783
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb5e74f
 
50094a0
 
 
 
bfd6783
 
bb5e74f
 
bfd6783
 
bb5e74f
 
 
bfd6783
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb5e74f
 
 
 
 
 
 
 
 
 
bfd6783
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb5e74f
 
 
 
 
 
 
 
 
0665581
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
---
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` | βœ… | `<you>/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 <period>` | `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 <category> <amount>` | 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", "<when> <what>"),)

    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://<your-tunnel>/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.