Spaces:
Running on Zero
Running on Zero
Username access, web dashboard and coach chat
Browse files- .env.example +23 -4
- README.md +4 -1
- app.py +35 -38
- finbot/auth.py +108 -0
- finbot/config.py +38 -4
- finbot/dashboard.py +408 -0
- finbot/features/__init__.py +2 -0
- finbot/features/base.py +3 -0
- finbot/features/dashboard.py +52 -0
- finbot/store.py +71 -1
- finbot/telegram.py +6 -0
- tests/test_app.py +7 -0
- tests/test_auth.py +72 -0
- tests/test_dashboard.py +232 -0
.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
|
| 15 |
-
#
|
| 16 |
-
#
|
| 17 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
| `
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 288 |
-
"<code>
|
| 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
|
| 374 |
-
"""
|
| 375 |
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
owner reads those over Telegram via /status, or from /admin/diagnostics
|
| 379 |
-
behind the webhook secret.
|
| 380 |
"""
|
| 381 |
-
|
| 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 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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 =
|
| 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")
|