Self-hosted Telegram bot for recurring bills, tasks and flexible reminders.
Encrypted local storage, per-user time zones, multi-currency totals, optional AI.
English Β· Π ΡΡΡΠΊΠΈΠΉ
Quick start Β· Docker Β· Configuration Β· Architecture Β· Reminder spec Β· Guide for AI agents Β· Security
SpendAlert keeps track of what you owe and what you promised to do. It runs as a single process, talks to Telegram over long polling, stores everything in a local SQLite file, and needs no web server, no cloud account and no database server.
A reminder-first tool, not another heavyweight personal-finance dashboard. The point is to capture an obligation in seconds, nag about it on a schedule you chose, and roll the date forward correctly when it is done.
| Product principle | How it shows up |
|---|---|
| Reliability before magic | Every core flow is a plain command; AI is optional and off by default |
| The user owns the cadence | Reminder plans are per-record, with quiet hours and one-tap snooze |
| Privacy that is visible, not implied | Sensitive fields encrypted, /privacy in the bot, user-scoped queries, the external AI provider named out loud |
| Recoverable beats perfect | Every AI mutation takes a per-user snapshot you can roll back |
- one-off and recurring payments and tasks;
- flexible reminder plans: days before the due date, on the day, and after it;
- snooze, mark paid/done, and quiet hours;
- per-user time zones, reminder hour and list sorting;
- RUB, USD, EUR, GBP, CNY with converted totals;
- calendar import from
.icsfiles; - Fernet encryption for titles, amounts, AI sessions and rollback snapshots;
- optional AI assistant through OpenRouter;
- per-user isolation and undo snapshots;
- runs as plain Python or under Docker Compose.
What a session looks like
You: /task
Bot: π New task β Title:
You: Send the report
Bot: π
Date
You: tomorrow 18:00
Bot: π Recurrence β [π One-off]
Bot: π When should I remind you? β [π
Due day only] [β
Done]
You: /list
Bot: π Tasks
β οΈ 1 | Send the report | 23.08.26 18:00 | one-off
The hero image above is a concept illustration, not a screenshot: it contains no user data.
You need Git and Python 3.10β3.12. Nothing is installed globally β everything lands in a virtual environment inside the checkout.
git clone https://github.com/dekotur/SpendAlert.git
cd SpendAlertWindows PowerShell:
py -3.12 -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install --requirement requirements.txtIf PowerShell refuses to run the activation script, allow it for this window
only with Set-ExecutionPolicy -Scope Process Bypass, then activate again.
Linux/macOS:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install --requirement requirements.txt- Open @BotFather in Telegram.
- Send
/newbotand follow the prompts. - Copy the token it gives you. Never commit it or paste it into an issue.
Copy the template β it contains no real values:
cp .env.example .env # PowerShell: Copy-Item .env.example .env
python scripts/generate_key.pyOpen .env and fill in:
| Key | Value |
|---|---|
TELEGRAM_BOT_TOKEN |
the token from BotFather |
DB_ENCRYPTION_KEY |
the string the generator just printed |
OPENROUTER_API_KEY |
optional, only for /ai |
ADMIN_CHAT_ID |
optional, enables operational alerts in Telegram |
DB_ENCRYPTION_KEY has no recovery path. Store it in a password manager: with a
different key, an existing database can no longer be read.
python scripts/security_audit.py
python test_bot.pyExpected output: Security audit passed and All 52 test groups passed. The
suite uses a throwaway database and a throwaway key β it never touches your real
data, and it needs no token and no .env.
python bot.pyOpen your bot in Telegram and send /start, then /help. Ctrl+C stops it.
Missing or empty required variables make the process exit immediately with the names of what is missing, before it ever contacts Telegram.
On first start, runtime files appear next to the code: the SQLite database, a
log, an exchange-rate cache and backups/, data/, logs/. All are listed in
.gitignore; set SPENDALERT_DATA_DIR to keep them somewhere else.
Use a throwaway test bot and invented records. Each row below is a full round-trip you can check by hand:
| Feature | Do this | Expect |
|---|---|---|
| Task with a deadline | /task β Send the report β tomorrow 18:00 β One-off β pick a plan β Done |
The task appears in /list and nags on the plan you chose |
| Recurring payment | /add β Internet β 799 β tomorrow β Month β Done |
Tapping "Paid" rolls the due date to the next period |
| List and totals | /list |
Payments and tasks are separated, near-due items highlighted, amounts converted to your currency |
| Snooze | Tap βͺοΈ +3 hours or βͺοΈ Tomorrow on a reminder |
The due time moves; no duplicate record is created |
| Calendar import | Send a synthetic .ics file |
The event becomes a task at the right local date and time |
| AI assistant | Configure OpenRouter, run /ai, then type what is coming up |
Free-text mode answers from your own records only |
Requires Docker Engine and Compose v2.
cp .env.example .env
docker compose build
docker compose run --rm spendalert python scripts/generate_key.pyPut the generated key and your bot token into .env, then start the service:
docker compose up -d
docker compose logs -f spendalertState lives in the named volume spendalert-data, mounted at /data. To
upgrade:
git pull --ff-only
docker compose up -d --builddocker compose down stops the bot and keeps the data. Adding -v deletes the
volume, and with it the database.
Everything is read from the environment; .env is loaded automatically in
development.
| Variable | Required | Purpose |
|---|---|---|
TELEGRAM_BOT_TOKEN |
yes | Your bot's token |
DB_ENCRYPTION_KEY |
yes | Fernet key for the encrypted SQLite fields |
OPENROUTER_API_KEY |
no | Enables the /ai assistant |
OPENROUTER_MODEL |
no | OpenRouter model id |
OPENROUTER_URL |
no | Endpoint of an OpenRouter-compatible API |
ADMIN_CHAT_ID |
no | Chat that receives operational alerts |
DATABASE_URL |
no | SQLite URL; defaults to a file in the state directory |
SPENDALERT_DATA_DIR |
no | Where database, logs, cache and backups live |
DB_THREAD_POOL_WORKERS |
no | Size of the blocking DB/file executor (default 32) |
BOT_DISK_BUDGET_MB |
no | Footprint budget for alerts; 0 disables the check |
DISK_FREE_ALERT_THRESHOLD_MB |
no | Free-space threshold for the disk alert |
Payments, tasks and reminders work with no OpenRouter key at all. Without one,
/ai reports an integration error and nothing else changes.
| Command | What it does |
|---|---|
/start |
Registers the user and shows the short privacy notice |
/add |
Adds a payment, step by step |
/task |
Adds a task, step by step |
/list |
Active records with the nearest due dates |
/edit ID |
Changes a record and its reminder plan |
/delete ID |
Deletes a record after confirmation |
/settings |
Time zone, currency, quiet hours, reminder hour, sorting |
/ai |
Turns the persistent AI assistant on or off |
/privacy |
Explains what is stored, encrypted and sent where |
/help |
Help and examples |
flowchart LR
U[Telegram user] --> B[Bot commands and conversations]
I[Calendar .ics] --> B
B --> D[(SQLite in WAL mode)]
D --- E[Fernet-encrypted fields]
B --> A[OpenRouter, optional]
B --> S[Scheduler]
S --> R[Reminder engine]
R --> D
S --> U
F[Exchange-rate APIs] --> B
| Area | Decision |
|---|---|
| Concurrency | Different chats run in parallel, but updates within one chat stay ordered β required for ConversationHandler and user_data safety |
| Reminder state | An episode model (n0..n3 / due / overdue) where only an actual send advances the counter, so a recompute cannot resurrect an exhausted wave β see docs/reminder-plan.md |
| Storage | SQLite in WAL mode, short transactions, a relocatable state directory, daily and per-user snapshots |
| Data protection | Titles, amounts, AI sessions and snapshots are Fernet-encrypted; the key lives only in the environment |
| Optional AI | OpenRouter receives the current user's scoped context and nothing else; manual commands never depend on it |
| Publication safety | CI runs the tests, the linter and a repository secret audit; Dependabot tracks Python, Actions and Docker updates |
| Module | Responsibility |
|---|---|
bot.py |
Telegram application: commands, conversations, callbacks, AI routing |
database.py |
SQLAlchemy models, migrations, user isolation, snapshots |
scheduler.py |
APScheduler jobs and reminder delivery |
reminder_plan.py |
Pure reminder-plan model and validation |
reminder_engine.py |
Episode calculation and persisted schedule state |
ai_handler.py |
Optional OpenRouter tool loop and user-scoped context |
crypto_utils.py |
Fernet field encryption |
ics_import.py |
Bounded .ics parsing |
currency.py |
Exchange-rate cache and conversion |
utils.py, config.py |
Dates and time zones; environment contract and paths |
test_bot.py |
Self-contained 52-group regression suite |
- single-instance self-hosted app, not a horizontally scalable SaaS;
- dates and scheduling metadata are not encrypted β the scheduler needs to query them by time;
- AI mode sends the context described in
/privacyto an external provider; - Telegram long polling only: no webhook deployment and no web UI.
By default the database (spendalert.db), logs, exchange-rate cache and backups
are created in the project directory; SPENDALERT_DATA_DIR moves all runtime
state elsewhere, and the Docker image uses /data.
SQLite runs in WAL mode. Sensitive text and numeric fields are encrypted with the Fernet key from the environment. Dates and scheduling metadata stay in plaintext because the scheduler queries them on every sweep.
Back up the state directory and the value of DB_ENCRYPTION_KEY. Either one
alone cannot restore anything.
Self-hosted does not mean zero-knowledge: the running process decrypts data to
build a reminder, and with /ai enabled the message plus the necessary context
goes to OpenRouter. Telegram sees messages as the transport. The bot tells users
the same thing through /privacy. If you operate a shared instance, its privacy
policy, key handling, backups and legal compliance are yours to own.
It exits immediately. .env must be in the project root, and both required
variables must be filled in without quotes or stray spaces around the name.
Telegram rejected the token. The bot exits with that message when the token is wrong. Copy it again from BotFather, with no quotes and no spaces around it, and make sure the bot has not been deleted there.
InvalidToken when reading the database. The DB_ENCRYPTION_KEY differs
from the one the database was created with. Restore the original key β
generating a new one cannot decrypt old rows.
Telegram returns Conflict. Two processes are polling the same token. Stop
the extra local or Docker instance.
/ai does not answer. Check OPENROUTER_API_KEY, your account balance and
the availability of the selected model. Nothing else depends on OpenRouter.
Time zones are not found on Windows. Install dependencies from
requirements.txt: tzdata is pinned there explicitly.
python -m pip install --requirement requirements-dev.txt
ruff check .
python scripts/security_audit.py
python test_bot.py
python -m compileall -q .CI runs the linter, the secret audit, and the full suite on Python 3.10 and 3.12. Contribution rules are in CONTRIBUTING.md; coding agents get their own contract in AGENTS.md. Report vulnerabilities through SECURITY.md, never in a public issue.
Before every push:
python scripts/security_audit.py && python test_bot.py && ruff check .
git status --shortRead that git status output. Never git add . unchecked. And if a token or key
ever does land in a commit, deleting the line in the next commit does not make it
safe β revoke and rotate the credential first.
To upgrade an existing Python install:
git pull --ff-only
python -m pip install --requirement requirements.txt
python test_bot.py
python bot.pyStop the old process and keep a copy of the state directory and the encryption key before upgrading.
