spacedout-bits commited on
Commit
50094a0
·
verified ·
1 Parent(s): bb5e74f

Username access, web dashboard and coach chat

Browse files
.env.example CHANGED
@@ -11,12 +11,31 @@ TELEGRAM_BOT_TOKEN=
11
  # Generate one with: python -c "import secrets; print(secrets.token_urlsafe(32))"
12
  TELEGRAM_WEBHOOK_SECRET=
13
 
14
- # Comma-separated Telegram numeric user IDs allowed to use the bot.
15
- # Leave EMPTY to allow nobody (safe default -- the bot replies with a refusal
16
- # and tells the sender their id, so you can read your own id off the reply).
17
- # Ignored when OPEN_ACCESS=true.
 
 
 
 
 
 
 
 
 
 
18
  ALLOWED_TELEGRAM_USER_IDS=
19
 
 
 
 
 
 
 
 
 
 
20
  # Let ANYONE on Telegram use the bot. Each user gets their own isolated ledger,
21
  # so this makes the bot multi-tenant -- it does NOT expose your spending to
22
  # other people. What it does expose is your wallet: every stranger's receipt
 
11
  # Generate one with: python -c "import secrets; print(secrets.token_urlsafe(32))"
12
  TELEGRAM_WEBHOOK_SECRET=
13
 
14
+ # Comma-separated Telegram @handles allowed to use the bot, with or without the
15
+ # @. This is the friendly way to configure access.
16
+ #
17
+ # Handles are matched case-insensitively. Note they are MUTABLE and can be
18
+ # recycled to a different person, so they gate access only -- your ledger is
19
+ # keyed on Telegram's immutable numeric id, which means renaming your handle
20
+ # never loses your history.
21
+ #
22
+ # Leave both lists EMPTY to allow nobody (safe default). Ignored when
23
+ # OPEN_ACCESS=true.
24
+ ALLOWED_TELEGRAM_USERNAMES=
25
+
26
+ # Numeric ids still work, and are the only option for users who have not set a
27
+ # @username at all.
28
  ALLOWED_TELEGRAM_USER_IDS=
29
 
30
+ # --- Web dashboard ---------------------------------------------------------
31
+ # Public origin used to build /dashboard sign-in links. Derived from SPACE_HOST
32
+ # on a Space; set explicitly when self-hosting.
33
+ PUBLIC_BASE_URL=
34
+
35
+ # How long a dashboard link stays valid. Short on purpose: the token rides in
36
+ # the URL, and URLs end up in history, referrers and screenshots.
37
+ DASHBOARD_LINK_MINUTES=30
38
+
39
  # Let ANYONE on Telegram use the bot. Each user gets their own isolated ledger,
40
  # so this makes the bot multi-tenant -- it does NOT expose your spending to
41
  # other people. What it does expose is your wallet: every stranger's receipt
README.md CHANGED
@@ -102,7 +102,10 @@ they stay out of your shell history):
102
  | `TELEGRAM_WEBHOOK_SECRET` | ✅ | Any random string; rejects forged webhook calls |
103
  | `HF_DATASET_REPO` | ✅ | `<you>/finbot-data` — **without this your data is lost on restart** |
104
  | `HF_TOKEN` | ✅ | **Write**-scoped token. Needed to mirror the ledger; also used for remote inference if you pick that backend |
105
- | `ALLOWED_TELEGRAM_USER_IDS` | ✅ | Your numeric Telegram ID (comma-separated for more). Not needed if `OPEN_ACCESS=true` |
 
 
 
106
  | `OPEN_ACCESS` | | `true` lets anyone use the bot, each with their own ledger. Default `false` |
107
  | `UPLOADS_PER_USER_PER_DAY` | | Caps model-backed uploads per user. Default `20`, `0` disables |
108
  | `LLM_BACKEND` | | `auto` (default), `local`, `remote`, `off`. See below |
 
102
  | `TELEGRAM_WEBHOOK_SECRET` | ✅ | Any random string; rejects forged webhook calls |
103
  | `HF_DATASET_REPO` | ✅ | `<you>/finbot-data` — **without this your data is lost on restart** |
104
  | `HF_TOKEN` | ✅ | **Write**-scoped token. Needed to mirror the ledger; also used for remote inference if you pick that backend |
105
+ | `ALLOWED_TELEGRAM_USERNAMES` | ✅ | Your Telegram @handle (comma-separated for more). Not needed if `OPEN_ACCESS=true` |
106
+ | `ALLOWED_TELEGRAM_USER_IDS` | | Numeric IDs — the only option for users with no @handle set |
107
+ | `PUBLIC_BASE_URL` | | Origin for dashboard links. Derived from `SPACE_HOST` on a Space |
108
+ | `DASHBOARD_LINK_MINUTES` | | Sign-in link lifetime. Default `30` |
109
  | `OPEN_ACCESS` | | `true` lets anyone use the bot, each with their own ledger. Default `false` |
110
  | `UPLOADS_PER_USER_PER_DAY` | | Caps model-backed uploads per user. Default `20`, `0` disables |
111
  | `LLM_BACKEND` | | `auto` (default), `local`, `remote`, `off`. See below |
app.py CHANGED
@@ -38,6 +38,7 @@ from finbot.advice import Advisor
38
  from finbot.analytics import today_in
39
  from finbot.backend import make_llm
40
  from finbot.config import get_settings
 
41
  from finbot.features import default_features
42
  from finbot.features.base import Attachment, BotContext, Services
43
  from finbot.ingest import Ingestor
@@ -74,8 +75,14 @@ class Runtime:
74
  limiter: RateLimiter
75
 
76
 
 
 
 
 
 
77
  def build_runtime() -> Runtime:
78
  """Synchronous construction, so it works from any startup path."""
 
79
  settings = get_settings()
80
  registry = FeatureRegistry()
81
  llm = make_llm(settings)
@@ -100,12 +107,13 @@ def build_runtime() -> Runtime:
100
  settings.uploads_per_user_per_day or "unlimited",
101
  )
102
 
103
- return Runtime(
104
  services=services,
105
  telegram=TelegramClient(settings.telegram_bot_token),
106
  seen=SeenUpdates(),
107
  limiter=RateLimiter(settings.uploads_per_user_per_day),
108
  )
 
109
 
110
 
111
  def space_webhook_url() -> str:
@@ -279,17 +287,32 @@ async def handle_update(rt: Runtime, parsed: ParsedUpdate) -> None:
279
  telegram = rt.telegram
280
  settings = services.settings
281
 
282
- if not settings.is_authorised(parsed.user_id):
283
- log.warning("Unauthorised user %s attempted access", parsed.user_id)
 
 
 
 
 
 
 
 
 
284
  await telegram.send_message(
285
  parsed.chat_id,
286
  "This is a private bot and you're not on its allowlist.\n\n"
287
- f"If it's yours, add <code>{parsed.user_id}</code> to "
288
- "<code>ALLOWED_TELEGRAM_USER_IDS</code> in the Space secrets "
289
  "and restart it.",
290
  )
291
  return
292
 
 
 
 
 
 
 
293
  try:
294
  attachment = None
295
  if parsed.has_file:
@@ -327,6 +350,7 @@ async def handle_update(rt: Runtime, parsed: ParsedUpdate) -> None:
327
  chat_id=parsed.chat_id,
328
  services=services,
329
  today=today_in(settings.timezone),
 
330
  text=parsed.text or parsed.caption,
331
  command=parsed.command,
332
  args=parsed.args,
@@ -370,43 +394,16 @@ async def handle_update(rt: Runtime, parsed: ParsedUpdate) -> None:
370
  # ----------------------------------------------------------------- gradio UI
371
 
372
 
373
- def build_ui() -> gr.Blocks:
374
- """The Space's visible page.
375
 
376
- Deliberately stateless: this Space is public, and transaction counts,
377
- configuration gaps and sync state are personal operational detail. The
378
- owner reads those over Telegram via /status, or from /admin/diagnostics
379
- behind the webhook secret.
380
  """
381
- with gr.Blocks(title="Oracle") as demo:
382
- gr.Markdown(
383
- f"""
384
- # 💸 Oracle
385
-
386
- An expense-tracking and budget-coaching bot. **It runs on Telegram** —
387
- there is nothing to operate on this page.
388
-
389
- Tell it what you spent (`coffee 250`), send it a receipt photo, or
390
- forward a bank statement as PDF/CSV/XLSX. Ask it `/month`,
391
- `/recurring` or `/advice`.
392
-
393
- Each Telegram user gets their own separate ledger.
394
-
395
- <sub>Built with <code>coco-finbot</code> v{__version__}.</sub>
396
- """
397
- )
398
-
399
- # Hidden, but real: binding a @spaces.GPU function into the Gradio app
400
- # is how ZeroGPU discovers that this Space uses its GPU. It warms the
401
- # model cache rather than doing nothing.
402
- with gr.Row(visible=False):
403
- warm_in = gr.Textbox(label="model")
404
- warm_out = gr.Textbox(label="status")
405
- gr.Button("warm").click(local_llm.warmup, warm_in, warm_out)
406
- return demo
407
 
408
 
409
- demo = build_ui()
410
 
411
 
412
  # ------------------------------------------------------------------- wiring
 
38
  from finbot.analytics import today_in
39
  from finbot.backend import make_llm
40
  from finbot.config import get_settings
41
+ from finbot.dashboard import build_ui
42
  from finbot.features import default_features
43
  from finbot.features.base import Attachment, BotContext, Services
44
  from finbot.ingest import Ingestor
 
75
  limiter: RateLimiter
76
 
77
 
78
+ #: Set by build_runtime so the Gradio UI, which is constructed at import time,
79
+ #: can reach the services once they exist.
80
+ _runtime: Optional["Runtime"] = None
81
+
82
+
83
  def build_runtime() -> Runtime:
84
  """Synchronous construction, so it works from any startup path."""
85
+ global _runtime
86
  settings = get_settings()
87
  registry = FeatureRegistry()
88
  llm = make_llm(settings)
 
107
  settings.uploads_per_user_per_day or "unlimited",
108
  )
109
 
110
+ _runtime = Runtime(
111
  services=services,
112
  telegram=TelegramClient(settings.telegram_bot_token),
113
  seen=SeenUpdates(),
114
  limiter=RateLimiter(settings.uploads_per_user_per_day),
115
  )
116
+ return _runtime
117
 
118
 
119
  def space_webhook_url() -> str:
 
287
  telegram = rt.telegram
288
  settings = services.settings
289
 
290
+ if not settings.is_authorised(parsed.user_id, parsed.username):
291
+ log.warning(
292
+ "Unauthorised access attempt from %s (@%s)",
293
+ parsed.user_id,
294
+ parsed.username or "no-handle",
295
+ )
296
+ who = (
297
+ f"@{parsed.username}"
298
+ if parsed.username
299
+ else f"id <code>{parsed.user_id}</code> (you have no @username set)"
300
+ )
301
  await telegram.send_message(
302
  parsed.chat_id,
303
  "This is a private bot and you're not on its allowlist.\n\n"
304
+ f"If it's yours, add {who} to "
305
+ "<code>ALLOWED_TELEGRAM_USERNAMES</code> in the Space secrets "
306
  "and restart it.",
307
  )
308
  return
309
 
310
+ # Refresh the handle registry on every message, so a rename is picked up
311
+ # without anything else needing to change.
312
+ await run_in_threadpool(
313
+ services.store.record_user, parsed.user_id, parsed.username, parsed.first_name
314
+ )
315
+
316
  try:
317
  attachment = None
318
  if parsed.has_file:
 
350
  chat_id=parsed.chat_id,
351
  services=services,
352
  today=today_in(settings.timezone),
353
+ username=parsed.username,
354
  text=parsed.text or parsed.caption,
355
  command=parsed.command,
356
  args=parsed.args,
 
394
  # ----------------------------------------------------------------- gradio UI
395
 
396
 
397
+ def _get_services() -> Optional[Services]:
398
+ """Late-bound accessor for the dashboard.
399
 
400
+ The UI object is built at import time, before any runtime exists, so it
401
+ reaches for services through this rather than capturing them.
 
 
402
  """
403
+ return _runtime.services if _runtime is not None else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
 
405
 
406
+ demo = build_ui(_get_services, local_llm.warmup)
407
 
408
 
409
  # ------------------------------------------------------------------- wiring
finbot/auth.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Signed, expiring tokens for the web dashboard.
2
+
3
+ The Space is public, so the dashboard cannot simply trust a user id in a query
4
+ string -- anyone could type someone else's. Instead the bot issues a token in
5
+ Telegram (where identity is already established) and the page verifies it.
6
+
7
+ Signed with ``TELEGRAM_WEBHOOK_SECRET``, so there is no additional secret to
8
+ manage or leak. Tokens are short-lived because a URL leaks easily: browser
9
+ history, referrer headers, a screenshot in a chat.
10
+
11
+ Nothing here can mutate the ledger. A stolen token, within its window, exposes
12
+ one person's spending summary and coach chat -- bad, but bounded, and the
13
+ window is minutes.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import hashlib
20
+ import hmac
21
+ import time
22
+ from typing import NamedTuple, Optional
23
+
24
+ #: Long enough to open the link and read it, short enough that a leaked URL in
25
+ #: browser history is stale by the time anyone finds it.
26
+ DEFAULT_TTL_SECONDS = 30 * 60
27
+
28
+ _SIG_BYTES = 16
29
+
30
+
31
+ class TokenError(ValueError):
32
+ """The token was malformed, tampered with, or expired."""
33
+
34
+
35
+ class Claims(NamedTuple):
36
+ user_id: int
37
+ expires_at: int
38
+
39
+ @property
40
+ def seconds_left(self) -> int:
41
+ return max(0, self.expires_at - int(time.time()))
42
+
43
+
44
+ def _b64encode(raw: bytes) -> str:
45
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
46
+
47
+
48
+ def _b64decode(text: str) -> bytes:
49
+ padding = "=" * (-len(text) % 4)
50
+ return base64.urlsafe_b64decode(text + padding)
51
+
52
+
53
+ def _sign(payload: bytes, secret: str) -> bytes:
54
+ return hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).digest()[
55
+ :_SIG_BYTES
56
+ ]
57
+
58
+
59
+ def issue(
60
+ user_id: int,
61
+ secret: str,
62
+ ttl_seconds: int = DEFAULT_TTL_SECONDS,
63
+ now: Optional[int] = None,
64
+ ) -> str:
65
+ """Mint a dashboard token for ``user_id``."""
66
+ if not secret:
67
+ raise TokenError("cannot issue tokens without a signing secret")
68
+
69
+ now = now if now is not None else int(time.time())
70
+ payload = f"{int(user_id)}.{now + int(ttl_seconds)}".encode("utf-8")
71
+ return f"{_b64encode(payload)}.{_b64encode(_sign(payload, secret))}"
72
+
73
+
74
+ def verify(token: str, secret: str, now: Optional[int] = None) -> Claims:
75
+ """Validate a token and return its claims, or raise :class:`TokenError`."""
76
+ if not secret:
77
+ raise TokenError("no signing secret configured")
78
+ if not token or "." not in token:
79
+ raise TokenError("malformed token")
80
+
81
+ encoded_payload, _, encoded_sig = token.rpartition(".")
82
+ try:
83
+ payload = _b64decode(encoded_payload)
84
+ signature = _b64decode(encoded_sig)
85
+ except Exception as exc:
86
+ raise TokenError("malformed token") from exc
87
+
88
+ # Constant-time: a timing-variable comparison would leak the signature
89
+ # byte by byte to anyone willing to measure.
90
+ if not hmac.compare_digest(signature, _sign(payload, secret)):
91
+ raise TokenError("bad signature")
92
+
93
+ try:
94
+ user_part, _, expiry_part = payload.decode("utf-8").partition(".")
95
+ claims = Claims(user_id=int(user_part), expires_at=int(expiry_part))
96
+ except Exception as exc:
97
+ raise TokenError("malformed payload") from exc
98
+
99
+ now = now if now is not None else int(time.time())
100
+ if claims.expires_at < now:
101
+ raise TokenError("token expired")
102
+
103
+ return claims
104
+
105
+
106
+ def dashboard_url(base_url: str, token: str) -> str:
107
+ base = (base_url or "").rstrip("/")
108
+ return f"{base}/?t={token}"
finbot/config.py CHANGED
@@ -9,6 +9,7 @@ what it is missing.
9
 
10
  from __future__ import annotations
11
 
 
12
  from functools import lru_cache
13
 
14
  from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -23,6 +24,11 @@ class Settings(BaseSettings):
23
  telegram_bot_token: str = ""
24
  telegram_webhook_secret: str = ""
25
  allowed_telegram_user_ids: str = ""
 
 
 
 
 
26
  #: Let anyone message the bot. Each Telegram user still gets their own
27
  #: isolated ledger -- open access makes this multi-tenant, it does not
28
  #: expose one user's spending to another. It does mean strangers spend the
@@ -60,6 +66,18 @@ class Settings(BaseSettings):
60
  # --- Behaviour ---
61
  default_currency: str = "INR"
62
  timezone: str = "Asia/Kolkata"
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
  @property
65
  def allowed_user_ids(self) -> frozenset[int]:
@@ -80,8 +98,22 @@ class Settings(BaseSettings):
80
  continue
81
  return frozenset(ids)
82
 
83
- def is_authorised(self, user_id: int) -> bool:
84
- return self.open_access or user_id in self.allowed_user_ids
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
 
86
  @property
87
  def llm_enabled(self) -> bool:
@@ -98,8 +130,10 @@ class Settings(BaseSettings):
98
  gaps.append("TELEGRAM_BOT_TOKEN (bot cannot send or receive)")
99
  if not self.telegram_webhook_secret:
100
  gaps.append("TELEGRAM_WEBHOOK_SECRET (webhook cannot be verified)")
101
- if not self.open_access and not self.allowed_user_ids:
102
- gaps.append("ALLOWED_TELEGRAM_USER_IDS (nobody is authorised yet)")
 
 
103
  if not self.hf_dataset_repo:
104
  gaps.append("HF_DATASET_REPO (data will be lost when the Space restarts)")
105
  if not self.hf_token:
 
9
 
10
  from __future__ import annotations
11
 
12
+ import os
13
  from functools import lru_cache
14
 
15
  from pydantic_settings import BaseSettings, SettingsConfigDict
 
24
  telegram_bot_token: str = ""
25
  telegram_webhook_secret: str = ""
26
  allowed_telegram_user_ids: str = ""
27
+ #: Comma-separated @handles, with or without the @. Friendlier to configure
28
+ #: than numeric ids, but note that handles are mutable and recyclable, so
29
+ #: they gate *access* only -- the ledger itself is keyed on the immutable
30
+ #: numeric id (see finbot.store).
31
+ allowed_telegram_usernames: str = ""
32
  #: Let anyone message the bot. Each Telegram user still gets their own
33
  #: isolated ledger -- open access makes this multi-tenant, it does not
34
  #: expose one user's spending to another. It does mean strangers spend the
 
66
  # --- Behaviour ---
67
  default_currency: str = "INR"
68
  timezone: str = "Asia/Kolkata"
69
+ #: Public origin for dashboard links. Derived from SPACE_HOST when unset.
70
+ public_base_url: str = ""
71
+ #: How long a dashboard link stays usable, in minutes.
72
+ dashboard_link_minutes: int = 30
73
+
74
+ @property
75
+ def base_url(self) -> str:
76
+ if self.public_base_url:
77
+ return self.public_base_url.rstrip("/")
78
+ if host := os.environ.get("SPACE_HOST"):
79
+ return f"https://{host.rstrip('/')}"
80
+ return ""
81
 
82
  @property
83
  def allowed_user_ids(self) -> frozenset[int]:
 
98
  continue
99
  return frozenset(ids)
100
 
101
+ @property
102
+ def allowed_usernames(self) -> frozenset[str]:
103
+ """Lower-cased handles without the leading @."""
104
+ handles: set[str] = set()
105
+ for chunk in self.allowed_telegram_usernames.split(","):
106
+ handle = chunk.strip().lstrip("@").lower()
107
+ if handle:
108
+ handles.add(handle)
109
+ return frozenset(handles)
110
+
111
+ def is_authorised(self, user_id: int, username: str = "") -> bool:
112
+ if self.open_access:
113
+ return True
114
+ if user_id in self.allowed_user_ids:
115
+ return True
116
+ return bool(username) and username.strip().lstrip("@").lower() in self.allowed_usernames
117
 
118
  @property
119
  def llm_enabled(self) -> bool:
 
130
  gaps.append("TELEGRAM_BOT_TOKEN (bot cannot send or receive)")
131
  if not self.telegram_webhook_secret:
132
  gaps.append("TELEGRAM_WEBHOOK_SECRET (webhook cannot be verified)")
133
+ if not self.open_access and not self.allowed_user_ids and not self.allowed_usernames:
134
+ gaps.append(
135
+ "ALLOWED_TELEGRAM_USERNAMES (nobody is authorised yet)"
136
+ )
137
  if not self.hf_dataset_repo:
138
  gaps.append("HF_DATASET_REPO (data will be lost when the Space restarts)")
139
  if not self.hf_token:
finbot/dashboard.py ADDED
@@ -0,0 +1,408 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The web dashboard and coach chat.
2
+
3
+ Served at ``/`` by Gradio. The Space is public, so **nothing renders without a
4
+ valid signed token** -- a visitor with no token sees only an explanation of what
5
+ the bot is. Tokens are minted by ``/dashboard`` in Telegram, where identity is
6
+ already established (see :mod:`finbot.auth`).
7
+
8
+ Data assembly is separated from rendering so the numbers can be tested without
9
+ standing up a browser.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from dataclasses import dataclass, field
16
+ from datetime import date
17
+ from typing import Any, Callable, Optional
18
+
19
+ import gradio as gr
20
+
21
+ from .analytics import (
22
+ BudgetStatus,
23
+ Recurring,
24
+ budget_status,
25
+ by_category,
26
+ detect_recurring,
27
+ dominant_currency,
28
+ month_bounds,
29
+ previous_month_bounds,
30
+ project_month_end,
31
+ today_in,
32
+ total_minor,
33
+ )
34
+ from .auth import TokenError, verify
35
+ from .categories import label as category_label
36
+ from .models import Expense
37
+ from .money import format_money, from_minor
38
+
39
+ log = logging.getLogger(__name__)
40
+
41
+ RECENT_LIMIT = 25
42
+
43
+
44
+ @dataclass
45
+ class DashboardData:
46
+ display_name: str = ""
47
+ currency: str = "INR"
48
+ today: date = field(default_factory=date.today)
49
+ month_total: int = 0
50
+ month_count: int = 0
51
+ last_month_total: int = 0
52
+ projected: int = 0
53
+ by_category: list[tuple[str, int]] = field(default_factory=list)
54
+ recent: list[Expense] = field(default_factory=list)
55
+ budgets: list[BudgetStatus] = field(default_factory=list)
56
+ recurring: list[Recurring] = field(default_factory=list)
57
+
58
+ @property
59
+ def delta_pct(self) -> Optional[float]:
60
+ if not self.last_month_total:
61
+ return None
62
+ return (self.month_total - self.last_month_total) / self.last_month_total * 100
63
+
64
+
65
+ def load_dashboard(services: Any, user_id: int) -> DashboardData:
66
+ """Gather everything the page shows, for one user."""
67
+ store = services.store
68
+ settings = services.settings
69
+ today = today_in(settings.timezone)
70
+
71
+ start, _ = month_bounds(today)
72
+ month = store.list_expenses(user_id, start, today)
73
+
74
+ prev_start, prev_end = previous_month_bounds(today)
75
+ # Same elapsed slice, so the comparison is like-for-like.
76
+ clipped = min(prev_end, prev_start + (today - start))
77
+ previous = store.list_expenses(user_id, prev_start, clipped)
78
+
79
+ everything = store.list_expenses(user_id)
80
+ currency = dominant_currency(month or everything, settings.default_currency)
81
+
82
+ scoped = [e for e in month if e.currency.upper() == currency]
83
+
84
+ return DashboardData(
85
+ display_name=store.display_name_for(user_id),
86
+ currency=currency,
87
+ today=today,
88
+ month_total=total_minor(scoped),
89
+ month_count=len(scoped),
90
+ last_month_total=total_minor(
91
+ [e for e in previous if e.currency.upper() == currency]
92
+ ),
93
+ projected=project_month_end(scoped, today),
94
+ by_category=by_category(scoped),
95
+ recent=store.list_expenses(user_id, limit=RECENT_LIMIT),
96
+ budgets=budget_status(store.get_budgets(user_id), month),
97
+ recurring=detect_recurring(everything),
98
+ )
99
+
100
+
101
+ # ------------------------------------------------------------------ rendering
102
+
103
+
104
+ def render_header(data: DashboardData) -> str:
105
+ return (
106
+ f"### {data.display_name}\n"
107
+ f"{data.today.strftime('%B %Y')} · showing {data.currency}"
108
+ )
109
+
110
+
111
+ def render_kpis(data: DashboardData) -> str:
112
+ if not data.month_count:
113
+ return (
114
+ "**Nothing logged this month yet.**\n\n"
115
+ "Send the bot something like `coffee 250` on Telegram and it will "
116
+ "appear here."
117
+ )
118
+
119
+ lines = [
120
+ f"**{format_money(data.month_total, data.currency)}** "
121
+ f"this month · {data.month_count} transactions",
122
+ ]
123
+
124
+ if (delta := data.delta_pct) is not None:
125
+ arrow = "▲" if delta > 0 else "▼"
126
+ lines.append(
127
+ f"{arrow} {abs(delta):.0f}% vs the same point last month "
128
+ f"({format_money(data.last_month_total, data.currency)})"
129
+ )
130
+
131
+ lines.append(
132
+ f"Projected month end: **{format_money(data.projected, data.currency)}**"
133
+ )
134
+
135
+ if data.by_category:
136
+ name, amount = data.by_category[0]
137
+ share = amount / data.month_total * 100 if data.month_total else 0
138
+ lines.append(
139
+ f"Biggest category: **{category_label(name)}** "
140
+ f"({format_money(amount, data.currency)}, {share:.0f}%)"
141
+ )
142
+
143
+ return "\n\n".join(lines)
144
+
145
+
146
+ def category_frame(data: DashboardData):
147
+ import pandas as pd
148
+
149
+ rows = [
150
+ {
151
+ "Category": category_label(name),
152
+ "Amount": round(from_minor(amount, data.currency), 2),
153
+ }
154
+ for name, amount in data.by_category[:10]
155
+ ]
156
+ return pd.DataFrame(rows or [{"Category": "—", "Amount": 0.0}])
157
+
158
+
159
+ def recent_frame(data: DashboardData):
160
+ import pandas as pd
161
+
162
+ rows = [
163
+ {
164
+ "Date": e.occurred_on.isoformat(),
165
+ "Amount": format_money(e.amount_minor, e.currency),
166
+ "Description": (e.merchant or e.description or "")[:48],
167
+ "Category": category_label(e.category),
168
+ }
169
+ for e in data.recent
170
+ ]
171
+ return pd.DataFrame(
172
+ rows or [{"Date": "—", "Amount": "—", "Description": "Nothing yet", "Category": "—"}]
173
+ )
174
+
175
+
176
+ def render_budgets(data: DashboardData) -> str:
177
+ if not data.budgets:
178
+ return "**Budgets**\n\nNone set. Send `/budget groceries 12000` on Telegram."
179
+
180
+ lines = ["**Budgets**", ""]
181
+ for status in data.budgets:
182
+ mark = "🔴" if status.over else ("🟡" if status.pct >= 80 else "🟢")
183
+ lines.append(
184
+ f"{mark} {category_label(status.category)} — "
185
+ f"{format_money(status.spent_minor, status.currency)} of "
186
+ f"{format_money(status.limit_minor, status.currency)} "
187
+ f"({status.pct:.0f}%)"
188
+ )
189
+ return "\n".join(lines)
190
+
191
+
192
+ def render_recurring(data: DashboardData) -> str:
193
+ if not data.recurring:
194
+ return (
195
+ "**Recurring**\n\nNothing detected yet — this needs a few months of "
196
+ "history to spot a pattern."
197
+ )
198
+
199
+ lines = ["**Recurring**", ""]
200
+ for item in data.recurring[:8]:
201
+ lines.append(
202
+ f"• {format_money(item.typical_minor, item.currency)}/mo — {item.label} "
203
+ f"_(seen in {item.months} months)_"
204
+ )
205
+ annual = sum(r.annualised_minor for r in data.recurring if r.currency == data.currency)
206
+ if annual:
207
+ lines.append("")
208
+ lines.append(f"≈ **{format_money(annual, data.currency)}/year** if all continue.")
209
+ return "\n".join(lines)
210
+
211
+
212
+ # ---------------------------------------------------------------------- coach
213
+
214
+
215
+ def coach_reply(services: Any, user_id: int, question: str) -> str:
216
+ """One coach turn, grounded in this user's own numbers."""
217
+ store = services.store
218
+ settings = services.settings
219
+ today = today_in(settings.timezone)
220
+
221
+ start, _ = month_bounds(today)
222
+ this_month = store.list_expenses(user_id, start, today)
223
+ prev_start, prev_end = previous_month_bounds(today)
224
+ last_month = store.list_expenses(user_id, prev_start, prev_end)
225
+ everything = store.list_expenses(user_id)
226
+
227
+ return services.advisor.advise(
228
+ expenses_this_month=this_month,
229
+ expenses_last_month=last_month,
230
+ recurring=detect_recurring(everything),
231
+ budgets=budget_status(store.get_budgets(user_id), this_month),
232
+ default_currency=settings.default_currency,
233
+ today=today,
234
+ question=question or None,
235
+ )
236
+
237
+
238
+ # ------------------------------------------------------------------- the page
239
+
240
+ _LANDING = """
241
+ # 💸 Oracle
242
+
243
+ An expense-tracking and budget-coaching bot that lives on **Telegram**.
244
+
245
+ Tell it what you spent (`coffee 250`), send a receipt photo, or forward a bank
246
+ statement as PDF/CSV/XLSX. Then ask it `/month`, `/recurring` or `/advice`.
247
+
248
+ To see this dashboard, send **`/dashboard`** to the bot — it will send you a
249
+ private sign-in link.
250
+ """
251
+
252
+ _EXPIRED = """
253
+ ### Link expired or invalid
254
+
255
+ Dashboard links are short-lived on purpose. Send **`/dashboard`** to the bot
256
+ again for a fresh one.
257
+ """
258
+
259
+
260
+ def build_ui(
261
+ get_services: Callable[[], Optional[Any]],
262
+ gpu_probe: Optional[Callable[..., str]] = None,
263
+ ) -> gr.Blocks:
264
+ """Assemble the page.
265
+
266
+ ``get_services`` is a callable rather than a value because the UI object is
267
+ built at import time, before the runtime exists.
268
+ """
269
+
270
+ def _authenticate(request: gr.Request) -> Optional[int]:
271
+ services = get_services()
272
+ if services is None:
273
+ return None
274
+ token = (request.query_params or {}).get("t", "")
275
+ if not token:
276
+ return None
277
+ try:
278
+ return verify(token, services.settings.telegram_webhook_secret).user_id
279
+ except TokenError as exc:
280
+ log.info("Rejected dashboard token: %s", exc)
281
+ return None
282
+
283
+ def on_load(request: gr.Request):
284
+ user_id = _authenticate(request)
285
+ has_token = bool((request.query_params or {}).get("t"))
286
+
287
+ if user_id is None:
288
+ return (
289
+ None,
290
+ gr.update(visible=True, value=_EXPIRED if has_token else _LANDING),
291
+ gr.update(visible=False),
292
+ "", "", None, None, "", "",
293
+ )
294
+
295
+ return (user_id, gr.update(visible=False), gr.update(visible=True), *_panels(user_id))
296
+
297
+ def _panels(user_id: int):
298
+ services = get_services()
299
+ data = load_dashboard(services, user_id)
300
+ return (
301
+ render_header(data),
302
+ render_kpis(data),
303
+ category_frame(data),
304
+ recent_frame(data),
305
+ render_budgets(data),
306
+ render_recurring(data),
307
+ )
308
+
309
+ def on_refresh(user_id):
310
+ if not user_id:
311
+ return "", "", None, None, "", ""
312
+ return _panels(int(user_id))
313
+
314
+ def on_chat(message: str, history: list, user_id):
315
+ history = list(history or [])
316
+ if not message or not message.strip():
317
+ return history, ""
318
+ if not user_id:
319
+ history.append({"role": "user", "content": message})
320
+ history.append(
321
+ {
322
+ "role": "assistant",
323
+ "content": "Your session expired. Send /dashboard to the bot again.",
324
+ }
325
+ )
326
+ return history, ""
327
+
328
+ history.append({"role": "user", "content": message})
329
+ try:
330
+ answer = coach_reply(get_services(), int(user_id), message.strip())
331
+ except Exception:
332
+ log.exception("Coach reply failed")
333
+ answer = "Something went wrong working that out. Try again in a moment."
334
+ history.append({"role": "assistant", "content": answer})
335
+ return history, ""
336
+
337
+ with gr.Blocks(title="Oracle", fill_height=True) as demo:
338
+ user_state = gr.State(None)
339
+
340
+ landing = gr.Markdown(_LANDING, visible=True)
341
+
342
+ with gr.Column(visible=False) as panel:
343
+ header = gr.Markdown()
344
+
345
+ with gr.Row():
346
+ with gr.Column(scale=3):
347
+ kpis = gr.Markdown()
348
+ chart = gr.BarPlot(
349
+ x="Category",
350
+ y="Amount",
351
+ title="Where it went this month",
352
+ height=280,
353
+ )
354
+ recent = gr.Dataframe(
355
+ headers=["Date", "Amount", "Description", "Category"],
356
+ interactive=False,
357
+ wrap=True,
358
+ label="Recent transactions",
359
+ )
360
+ with gr.Row():
361
+ budgets_md = gr.Markdown()
362
+ recurring_md = gr.Markdown()
363
+ refresh = gr.Button("Refresh", size="sm")
364
+
365
+ with gr.Column(scale=2):
366
+ gr.Markdown("### 🧭 Coach")
367
+ chat = gr.Chatbot(
368
+ type="messages",
369
+ height=460,
370
+ show_label=False,
371
+ # Model output is rendered as text, never markup.
372
+ allow_tags=False,
373
+ placeholder=(
374
+ "Ask about your own spending — "
375
+ "\"what changed this month?\", \"where can I cut back?\""
376
+ ),
377
+ )
378
+ question = gr.Textbox(
379
+ placeholder="Ask about your spending…",
380
+ show_label=False,
381
+ submit_btn=True,
382
+ )
383
+ gr.Markdown(
384
+ "<sub>Budgeting observations from your own logged data. "
385
+ "Not financial advice.</sub>"
386
+ )
387
+
388
+ # Hidden: binding a @spaces.GPU function into the app graph is how
389
+ # ZeroGPU discovers this Space uses its GPU.
390
+ if gpu_probe is not None:
391
+ with gr.Row(visible=False):
392
+ warm_in = gr.Textbox(label="model")
393
+ warm_out = gr.Textbox(label="status")
394
+ gr.Button("warm").click(gpu_probe, warm_in, warm_out)
395
+
396
+ panel_outputs = [header, kpis, chart, recent, budgets_md, recurring_md]
397
+
398
+ demo.load(
399
+ on_load,
400
+ inputs=None,
401
+ outputs=[user_state, landing, panel, *panel_outputs],
402
+ )
403
+ refresh.click(on_refresh, inputs=user_state, outputs=panel_outputs)
404
+ question.submit(
405
+ on_chat, inputs=[question, chat, user_state], outputs=[chat, question]
406
+ )
407
+
408
+ return demo
finbot/features/__init__.py CHANGED
@@ -19,6 +19,7 @@ from .base import (
19
  from .budgets import BudgetsFeature
20
  from .coaching import CoachingFeature
21
  from .core import CoreFeature
 
22
  from .expenses import ExpensesFeature
23
 
24
  __all__ = [
@@ -42,6 +43,7 @@ def default_features() -> list[Feature]:
42
  return [
43
  ExpensesFeature(),
44
  BudgetsFeature(),
 
45
  CoachingFeature(),
46
  CoreFeature(),
47
  ]
 
19
  from .budgets import BudgetsFeature
20
  from .coaching import CoachingFeature
21
  from .core import CoreFeature
22
+ from .dashboard import DashboardFeature
23
  from .expenses import ExpensesFeature
24
 
25
  __all__ = [
 
43
  return [
44
  ExpensesFeature(),
45
  BudgetsFeature(),
46
+ DashboardFeature(),
47
  CoachingFeature(),
48
  CoreFeature(),
49
  ]
finbot/features/base.py CHANGED
@@ -82,6 +82,9 @@ class BotContext:
82
  chat_id: int
83
  services: Services
84
  today: date
 
 
 
85
  text: str = ""
86
  command: str = ""
87
  args: str = ""
 
82
  chat_id: int
83
  services: Services
84
  today: date
85
+ #: Current @handle without the @, empty if unset. Display and access only --
86
+ #: everything stored is keyed on the immutable user_id.
87
+ username: str = ""
88
  text: str = ""
89
  command: str = ""
90
  args: str = ""
finbot/features/dashboard.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The /dashboard command: hands out a signed link to the web view."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Sequence
6
+
7
+ from .. import render
8
+ from ..auth import TokenError, dashboard_url, issue
9
+ from .base import BotContext, CommandSpec, Reply, SimpleFeature
10
+
11
+
12
+ class DashboardFeature(SimpleFeature):
13
+ name = "dashboard"
14
+ description = "📊 Dashboard"
15
+ priority = 25
16
+
17
+ def commands(self) -> Sequence[CommandSpec]:
18
+ return (CommandSpec("dashboard", "Open your web dashboard and coach"),)
19
+
20
+ def cmd_dashboard(self, ctx: BotContext) -> Reply:
21
+ settings = ctx.settings
22
+
23
+ if not settings.telegram_webhook_secret:
24
+ return Reply(
25
+ "The dashboard needs <code>TELEGRAM_WEBHOOK_SECRET</code> set "
26
+ "(it signs the login link)."
27
+ )
28
+ if not settings.base_url:
29
+ return Reply(
30
+ "I don't know my own public address. Set "
31
+ "<code>PUBLIC_BASE_URL</code> in the Space settings."
32
+ )
33
+
34
+ try:
35
+ token = issue(
36
+ ctx.user_id,
37
+ settings.telegram_webhook_secret,
38
+ ttl_seconds=settings.dashboard_link_minutes * 60,
39
+ )
40
+ except TokenError as exc:
41
+ return Reply(f"Couldn't create a login link: {render.escape(str(exc))}")
42
+
43
+ url = dashboard_url(settings.base_url, token)
44
+ minutes = settings.dashboard_link_minutes
45
+
46
+ return Reply(
47
+ f'📊 <a href="{render.escape(url)}">Open your dashboard</a>\n\n'
48
+ f"<i>This link signs you in and works for {minutes} minutes. "
49
+ "It's personal — anyone with it can see your spending until it "
50
+ "expires, so don't forward it.</i>",
51
+ disable_preview=True,
52
+ )
finbot/store.py CHANGED
@@ -29,7 +29,7 @@ from .models import Budget, Expense
29
  log = logging.getLogger(__name__)
30
 
31
  DB_FILENAME = "finbot.db"
32
- SCHEMA_VERSION = 1
33
 
34
  _SCHEMA = """
35
  CREATE TABLE IF NOT EXISTS expenses (
@@ -69,6 +69,21 @@ CREATE TABLE IF NOT EXISTS budgets (
69
  PRIMARY KEY (user_id, category, period)
70
  );
71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  CREATE TABLE IF NOT EXISTS meta (
73
  key TEXT PRIMARY KEY,
74
  value TEXT NOT NULL
@@ -353,6 +368,61 @@ class Store:
353
  ).fetchone()
354
  return int(row["n"])
355
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  # --------------------------------------------------------------- budgets
357
 
358
  def set_budget(self, budget: Budget) -> Budget:
 
29
  log = logging.getLogger(__name__)
30
 
31
  DB_FILENAME = "finbot.db"
32
+ SCHEMA_VERSION = 2 # 2 adds the users table
33
 
34
  _SCHEMA = """
35
  CREATE TABLE IF NOT EXISTS expenses (
 
69
  PRIMARY KEY (user_id, category, period)
70
  );
71
 
72
+ -- Handle registry. The ledger is keyed on user_id and never on username,
73
+ -- because Telegram handles are mutable and can be recycled to a different
74
+ -- person: keying on one would orphan a history on rename, or hand it to
75
+ -- whoever claimed the old name. Username is stored for display and lookup
76
+ -- only, refreshed on every message.
77
+ CREATE TABLE IF NOT EXISTS users (
78
+ user_id INTEGER PRIMARY KEY,
79
+ username TEXT NOT NULL DEFAULT '',
80
+ first_name TEXT NOT NULL DEFAULT '',
81
+ first_seen TEXT NOT NULL,
82
+ last_seen TEXT NOT NULL
83
+ );
84
+
85
+ CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
86
+
87
  CREATE TABLE IF NOT EXISTS meta (
88
  key TEXT PRIMARY KEY,
89
  value TEXT NOT NULL
 
368
  ).fetchone()
369
  return int(row["n"])
370
 
371
+ # ----------------------------------------------------------------- users
372
+
373
+ def record_user(
374
+ self, user_id: int, username: str = "", first_name: str = ""
375
+ ) -> None:
376
+ """Note that we have seen this person, refreshing their handle.
377
+
378
+ Called on every message so a renamed handle is picked up without any
379
+ migration -- the id is what everything else hangs off.
380
+ """
381
+ handle = (username or "").lstrip("@")
382
+ now = utc_now().isoformat()
383
+ with self._lock:
384
+ self._conn.execute(
385
+ """INSERT INTO users (user_id, username, first_name, first_seen, last_seen)
386
+ VALUES (?,?,?,?,?)
387
+ ON CONFLICT(user_id) DO UPDATE SET
388
+ username = excluded.username,
389
+ first_name = excluded.first_name,
390
+ last_seen = excluded.last_seen""",
391
+ (user_id, handle, first_name or "", now, now),
392
+ )
393
+ self._conn.commit()
394
+
395
+ def username_for(self, user_id: int) -> str:
396
+ with self._lock:
397
+ row = self._conn.execute(
398
+ "SELECT username FROM users WHERE user_id = ?", (user_id,)
399
+ ).fetchone()
400
+ return row["username"] if row else ""
401
+
402
+ def display_name_for(self, user_id: int) -> str:
403
+ """Handle if we know one, else first name, else the bare id."""
404
+ with self._lock:
405
+ row = self._conn.execute(
406
+ "SELECT username, first_name FROM users WHERE user_id = ?",
407
+ (user_id,),
408
+ ).fetchone()
409
+ if not row:
410
+ return str(user_id)
411
+ if row["username"]:
412
+ return f"@{row['username']}"
413
+ return row["first_name"] or str(user_id)
414
+
415
+ def user_id_for(self, username: str) -> Optional[int]:
416
+ """Resolve a handle to its id. Case-insensitive; None if unknown."""
417
+ handle = (username or "").strip().lstrip("@").lower()
418
+ if not handle:
419
+ return None
420
+ with self._lock:
421
+ row = self._conn.execute(
422
+ "SELECT user_id FROM users WHERE LOWER(username) = ?", (handle,)
423
+ ).fetchone()
424
+ return int(row["user_id"]) if row else None
425
+
426
  # --------------------------------------------------------------- budgets
427
 
428
  def set_budget(self, budget: Budget) -> Budget:
finbot/telegram.py CHANGED
@@ -29,6 +29,10 @@ class ParsedUpdate:
29
  update_id: int
30
  user_id: int
31
  chat_id: int
 
 
 
 
32
  text: str = ""
33
  command: str = ""
34
  args: str = ""
@@ -59,6 +63,8 @@ def parse_update(update: dict[str, Any], bot_username: str = "") -> Optional[Par
59
  update_id=int(update.get("update_id", 0)),
60
  user_id=int(sender["id"]),
61
  chat_id=int(chat["id"]),
 
 
62
  caption=(message.get("caption") or "").strip(),
63
  )
64
 
 
29
  update_id: int
30
  user_id: int
31
  chat_id: int
32
+ #: Current @handle without the @, empty if the user has not set one.
33
+ #: Mutable -- never use as a storage key, only for access checks and display.
34
+ username: str = ""
35
+ first_name: str = ""
36
  text: str = ""
37
  command: str = ""
38
  args: str = ""
 
63
  update_id=int(update.get("update_id", 0)),
64
  user_id=int(sender["id"]),
65
  chat_id=int(chat["id"]),
66
+ username=(sender.get("username") or "").lstrip("@"),
67
+ first_name=(sender.get("first_name") or "").strip(),
68
  caption=(message.get("caption") or "").strip(),
69
  )
70
 
tests/test_app.py CHANGED
@@ -110,6 +110,13 @@ class TestEndpoints:
110
  for leak in ("mirroring", "not configured", "hf_dataset_repo", "telegram_bot_token"):
111
  assert leak not in text
112
 
 
 
 
 
 
 
 
113
  def test_diagnostics_requires_the_secret(self, client):
114
  assert client.get("/admin/diagnostics").status_code == 403
115
  assert client.get(
 
110
  for leak in ("mirroring", "not configured", "hf_dataset_repo", "telegram_bot_token"):
111
  assert leak not in text
112
 
113
+ def test_public_page_shows_only_the_landing_copy(self, client):
114
+ # No token: a visitor must see the explainer, never anyone's numbers.
115
+ send(client, "coffee 250")
116
+ text = client.get("/").text
117
+ assert "sign-in link" in text
118
+ assert "₹250.00" not in text
119
+
120
  def test_diagnostics_requires_the_secret(self, client):
121
  assert client.get("/admin/diagnostics").status_code == 403
122
  assert client.get(
tests/test_auth.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+
3
+ import pytest
4
+
5
+ from finbot.auth import Claims, TokenError, dashboard_url, issue, verify
6
+
7
+ SECRET = "a-test-signing-secret"
8
+
9
+
10
+ class TestRoundTrip:
11
+ def test_issue_and_verify(self):
12
+ token = issue(42, SECRET)
13
+ claims = verify(token, SECRET)
14
+ assert claims.user_id == 42
15
+
16
+ def test_large_user_ids(self):
17
+ # Telegram ids are well past 32-bit.
18
+ assert verify(issue(7_000_000_123, SECRET), SECRET).user_id == 7_000_000_123
19
+
20
+ def test_tokens_are_url_safe(self):
21
+ token = issue(42, SECRET)
22
+ assert "/" not in token and "+" not in token and "=" not in token
23
+
24
+ def test_seconds_left(self):
25
+ claims = Claims(user_id=1, expires_at=int(time.time()) + 60)
26
+ assert 55 <= claims.seconds_left <= 60
27
+
28
+
29
+ class TestRejection:
30
+ def test_wrong_secret(self):
31
+ token = issue(42, SECRET)
32
+ with pytest.raises(TokenError, match="bad signature"):
33
+ verify(token, "a-different-secret")
34
+
35
+ def test_tampered_payload(self):
36
+ # Swap the user id for someone else's and the signature must fail.
37
+ token = issue(42, SECRET)
38
+ forged = issue(99, SECRET).split(".")[0] + "." + token.split(".")[1]
39
+ with pytest.raises(TokenError):
40
+ verify(forged, SECRET)
41
+
42
+ def test_expired(self):
43
+ past = int(time.time()) - 100
44
+ token = issue(42, SECRET, ttl_seconds=1, now=past)
45
+ with pytest.raises(TokenError, match="expired"):
46
+ verify(token, SECRET)
47
+
48
+ def test_still_valid_just_before_expiry(self):
49
+ now = int(time.time())
50
+ token = issue(42, SECRET, ttl_seconds=60, now=now)
51
+ assert verify(token, SECRET, now=now + 59).user_id == 42
52
+
53
+ @pytest.mark.parametrize("bad", ["", "nonsense", "no-dot", "a.b.c.d", "...."])
54
+ def test_malformed(self, bad):
55
+ with pytest.raises(TokenError):
56
+ verify(bad, SECRET)
57
+
58
+ def test_no_secret_configured(self):
59
+ with pytest.raises(TokenError):
60
+ verify(issue(42, SECRET), "")
61
+
62
+ def test_cannot_issue_without_a_secret(self):
63
+ with pytest.raises(TokenError):
64
+ issue(42, "")
65
+
66
+
67
+ class TestURL:
68
+ def test_builds_a_query_url(self):
69
+ assert dashboard_url("https://x.hf.space/", "abc") == "https://x.hf.space/?t=abc"
70
+
71
+ def test_handles_missing_trailing_slash(self):
72
+ assert dashboard_url("https://x.hf.space", "abc") == "https://x.hf.space/?t=abc"
tests/test_dashboard.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import timedelta
2
+ from types import SimpleNamespace
3
+
4
+ import pytest
5
+
6
+ from finbot.analytics import today_in
7
+ from finbot.config import Settings
8
+ from finbot.dashboard import (
9
+ category_frame,
10
+ coach_reply,
11
+ load_dashboard,
12
+ recent_frame,
13
+ render_budgets,
14
+ render_header,
15
+ render_kpis,
16
+ render_recurring,
17
+ )
18
+ from finbot.models import Budget, Expense
19
+ from finbot.store import Store
20
+
21
+
22
+ def make_settings(tmp_path, **overrides):
23
+ base = dict(
24
+ data_dir=str(tmp_path),
25
+ hf_dataset_repo="",
26
+ hf_token="",
27
+ telegram_bot_token="",
28
+ telegram_webhook_secret="sekrit",
29
+ allowed_telegram_user_ids="",
30
+ allowed_telegram_usernames="",
31
+ default_currency="INR",
32
+ timezone="Asia/Kolkata",
33
+ )
34
+ base.update(overrides)
35
+ return Settings(**base)
36
+
37
+
38
+ @pytest.fixture
39
+ def services(tmp_path):
40
+ settings = make_settings(tmp_path)
41
+ store = Store(settings)
42
+ advisor = SimpleNamespace(advise=lambda **kw: f"advice about: {kw.get('question')}")
43
+ yield SimpleNamespace(store=store, settings=settings, advisor=advisor)
44
+ store.close()
45
+
46
+
47
+ def add(store, amount, category="food_dining", day=None, merchant="", currency="INR"):
48
+ store.add_expense(
49
+ Expense(
50
+ user_id=1,
51
+ amount_minor=amount,
52
+ currency=currency,
53
+ description=merchant or "thing",
54
+ category=category,
55
+ occurred_on=day or today_in("Asia/Kolkata"),
56
+ merchant=merchant,
57
+ )
58
+ )
59
+
60
+
61
+ class TestUserRegistry:
62
+ def test_records_and_reads_back(self, services):
63
+ services.store.record_user(1, "alice", "Alice")
64
+ assert services.store.username_for(1) == "alice"
65
+ assert services.store.display_name_for(1) == "@alice"
66
+
67
+ def test_strips_the_at_sign(self, services):
68
+ services.store.record_user(1, "@bob")
69
+ assert services.store.username_for(1) == "bob"
70
+
71
+ def test_rename_updates_in_place(self, services):
72
+ services.store.record_user(1, "alice")
73
+ services.store.record_user(1, "alice2")
74
+ assert services.store.username_for(1) == "alice2"
75
+ # Crucially the id is unchanged, so the ledger is not orphaned.
76
+ assert services.store.user_id_for("alice2") == 1
77
+
78
+ def test_lookup_is_case_insensitive(self, services):
79
+ services.store.record_user(1, "Alice")
80
+ assert services.store.user_id_for("@ALICE") == 1
81
+
82
+ def test_unknown_handle(self, services):
83
+ assert services.store.user_id_for("nobody") is None
84
+ assert services.store.user_id_for("") is None
85
+
86
+ def test_falls_back_to_first_name_then_id(self, services):
87
+ services.store.record_user(2, "", "Bob")
88
+ assert services.store.display_name_for(2) == "Bob"
89
+ assert services.store.display_name_for(999) == "999"
90
+
91
+
92
+ class TestUsernameAuthorisation:
93
+ def test_username_allowlist(self, tmp_path):
94
+ s = make_settings(tmp_path, allowed_telegram_usernames="alice, @bob")
95
+ assert s.is_authorised(111, "alice")
96
+ assert s.is_authorised(222, "@bob")
97
+ assert not s.is_authorised(333, "carol")
98
+
99
+ def test_case_insensitive(self, tmp_path):
100
+ s = make_settings(tmp_path, allowed_telegram_usernames="Alice")
101
+ assert s.is_authorised(1, "ALICE")
102
+
103
+ def test_numeric_ids_still_work(self, tmp_path):
104
+ s = make_settings(tmp_path, allowed_telegram_user_ids="42")
105
+ assert s.is_authorised(42, "")
106
+
107
+ def test_no_username_and_not_in_id_list(self, tmp_path):
108
+ s = make_settings(tmp_path, allowed_telegram_usernames="alice")
109
+ assert not s.is_authorised(1, "")
110
+
111
+ def test_open_access_overrides(self, tmp_path):
112
+ s = make_settings(tmp_path, open_access=True)
113
+ assert s.is_authorised(999, "stranger")
114
+
115
+ def test_missing_required_mentions_usernames(self, tmp_path):
116
+ gaps = make_settings(tmp_path).missing_required()
117
+ assert any("ALLOWED_TELEGRAM_USERNAMES" in g for g in gaps)
118
+
119
+ def test_username_allowlist_satisfies_the_check(self, tmp_path):
120
+ gaps = make_settings(
121
+ tmp_path, allowed_telegram_usernames="alice"
122
+ ).missing_required()
123
+ assert not any("ALLOWED" in g for g in gaps)
124
+
125
+
126
+ class TestLoadDashboard:
127
+ def test_empty_ledger(self, services):
128
+ data = load_dashboard(services, 1)
129
+ assert data.month_total == 0
130
+ assert data.month_count == 0
131
+ assert data.recent == []
132
+
133
+ def test_totals_this_month(self, services):
134
+ add(services.store, 25000)
135
+ add(services.store, 15000, "transport")
136
+ data = load_dashboard(services, 1)
137
+ assert data.month_total == 40000
138
+ assert data.month_count == 2
139
+
140
+ def test_uses_the_display_name(self, services):
141
+ services.store.record_user(1, "alice")
142
+ assert load_dashboard(services, 1).display_name == "@alice"
143
+
144
+ def test_category_breakdown_is_sorted(self, services):
145
+ add(services.store, 10000, "food_dining")
146
+ add(services.store, 50000, "rent")
147
+ data = load_dashboard(services, 1)
148
+ assert data.by_category[0][0] == "rent"
149
+
150
+ def test_only_counts_the_dominant_currency(self, services):
151
+ add(services.store, 10000, currency="INR")
152
+ add(services.store, 20000, currency="INR")
153
+ add(services.store, 999999, currency="USD")
154
+ data = load_dashboard(services, 1)
155
+ assert data.currency == "INR"
156
+ assert data.month_total == 30000 # never sums across currencies
157
+
158
+ def test_is_scoped_to_one_user(self, services):
159
+ add(services.store, 25000)
160
+ other = Expense(
161
+ user_id=2, amount_minor=99999, currency="INR", description="x",
162
+ category="other", occurred_on=today_in("Asia/Kolkata"),
163
+ )
164
+ services.store.add_expense(other)
165
+ assert load_dashboard(services, 1).month_total == 25000
166
+
167
+ def test_budgets_included(self, services):
168
+ services.store.set_budget(Budget(1, "food_dining", 100000, "INR"))
169
+ add(services.store, 25000)
170
+ data = load_dashboard(services, 1)
171
+ assert len(data.budgets) == 1
172
+ assert data.budgets[0].spent_minor == 25000
173
+
174
+
175
+ class TestRendering:
176
+ def test_header(self, services):
177
+ services.store.record_user(1, "alice")
178
+ assert "@alice" in render_header(load_dashboard(services, 1))
179
+
180
+ def test_kpis_empty_state_is_helpful(self, services):
181
+ assert "Nothing logged" in render_kpis(load_dashboard(services, 1))
182
+
183
+ def test_kpis_show_the_total(self, services):
184
+ add(services.store, 25000)
185
+ assert "₹250.00" in render_kpis(load_dashboard(services, 1))
186
+
187
+ def test_kpis_show_the_top_category(self, services):
188
+ add(services.store, 25000, "groceries")
189
+ assert "Groceries" in render_kpis(load_dashboard(services, 1))
190
+
191
+ def test_category_frame_has_expected_columns(self, services):
192
+ add(services.store, 25000)
193
+ frame = category_frame(load_dashboard(services, 1))
194
+ assert list(frame.columns) == ["Category", "Amount"]
195
+
196
+ def test_category_frame_survives_an_empty_ledger(self, services):
197
+ assert len(category_frame(load_dashboard(services, 1))) == 1
198
+
199
+ def test_recent_frame(self, services):
200
+ add(services.store, 25000, merchant="Blue Tokai")
201
+ frame = recent_frame(load_dashboard(services, 1))
202
+ assert "Blue Tokai" in frame["Description"].tolist()
203
+
204
+ def test_recent_frame_empty(self, services):
205
+ frame = recent_frame(load_dashboard(services, 1))
206
+ assert "Nothing yet" in frame["Description"].tolist()
207
+
208
+ def test_budgets_empty_state(self, services):
209
+ assert "None set" in render_budgets(load_dashboard(services, 1))
210
+
211
+ def test_budgets_rendered(self, services):
212
+ services.store.set_budget(Budget(1, "rent", 100000, "INR"))
213
+ assert "Rent" in render_budgets(load_dashboard(services, 1))
214
+
215
+ def test_recurring_empty_state(self, services):
216
+ assert "Nothing detected" in render_recurring(load_dashboard(services, 1))
217
+
218
+ def test_recurring_rendered(self, services):
219
+ today = today_in("Asia/Kolkata")
220
+ for months_back in (0, 1, 2):
221
+ day = (today.replace(day=1) - timedelta(days=31 * months_back)).replace(day=5)
222
+ add(services.store, 64900, "subscriptions", day=day, merchant="Netflix")
223
+ assert "Netflix" in render_recurring(load_dashboard(services, 1))
224
+
225
+
226
+ class TestCoach:
227
+ def test_passes_the_question_through(self, services):
228
+ add(services.store, 25000)
229
+ assert "where can I cut back" in coach_reply(services, 1, "where can I cut back")
230
+
231
+ def test_works_with_no_data(self, services):
232
+ assert coach_reply(services, 1, "hello")