(ex)SpenserBot is a Telegram bot that extracts structured expense data from receipt photos and free-text messages using LLM-powered extraction. Built with a hexagonal (ports & adapters) architecture.
- Receipt photo extraction: Send a photo of a receipt — the bot extracts amount, currency, merchant, date, and optional category
- Free-text expense logging: Type "lunch 15.50 eur at Mario's Pizzeria on 2026-07-10" and it's saved automatically
- Correction loop: If the LLM can't extract all required fields, the bot asks for missing info. You can refine up to 3 times before manual intervention is suggested
- Monthly CSV reports:
/reportgenerates a CSV with all your expenses for the current month - User isolation: Each Telegram user sees only their own expenses
- User authorization whitelist: Only Telegram user IDs listed in a JSON config file can interact with the bot; unauthorized attempts are silently ignored and written to
unauthorized.log
- Python 3.12+ (uses
X | Yunions, PEP 695 generics) - uv — package manager and tool runner (install guide)
- Telegram Bot Token — create a bot via @BotFather on Telegram
- LLM API access — any OpenAI-compatible endpoint (see Configuration)
# Clone and install
git clone <repo-url>
cd expense-report-bot
uv sync
# Install pre-commit hooks (gitleaks secret scanning)
uv run pre-commit install
# Set up environment
cp .env.example .env
# Edit .env with your actual credentialsCreate a .env file (or export the variables directly):
# Required
TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
LLM_BASE_URL=https://api.openai.com/v1 # or your own LLM endpoint
LLM_API_KEY=sk-...
LLM_MODEL=gpt-4o # or any model your endpoint supports
# Optional
EXPENSE_DB_PATH=expenses.db # defaults to expenses.db
# Authorization
AUTHORIZED_USERS_CONFIG_PATH=authorized-users.json # JSON whitelist of Telegram user IDs
UNAUTHORIZED_LOG_PATH=unauthorized.log # optional; defaults beside EXPENSE_DB_PATHCreate a whitelist JSON file before using the bot:
{
"authorized_users": ["123456789"]
}Values must be numeric strings. Telegram user IDs that are not listed are silently ignored. Each unauthorized attempt appends one line to the dedicated audit file:
2026-07-19T12:00:00Z user_id=987654321
Failure behavior:
- Missing
AUTHORIZED_USERS_CONFIG_PATH, missing whitelist file, or unreadable whitelist file: the bot starts with no authorized users and logs a warning. - Malformed JSON: startup fails.
- Valid JSON with invalid schema: the bot starts with no authorized users and logs a warning.
- Unwritable unauthorized audit log: startup fails.
uv run expense-botThe bot starts polling Telegram for messages. Send it:
- A receipt photo — it extracts expense data and confirms with you
- Free-form text like
"taxi 25 usd transport"— parsed and saved /report— generates a CSV of current month's expenses/start— welcome message with usage instructions
# From an image
uv run expense-extract --user-id 123 --db test.db extract-from-image receipt.jpg
# From free text
uv run expense-extract extract-from-text "lunch 15.50 eur at Mario's Pizzeria on 2026-07-10"The Compose setup uses two separate env files for different purposes:
| File | Purpose | Used by |
|---|---|---|
.env |
Bot runtime environment variables (TELEGRAM_BOT_TOKEN, LLM_*, AUTHORIZED_USERS_CONFIG_PATH etc.) |
docker-compose.yml env_file: directive — injected into the running container |
.env.deploy |
Compose file interpolation (${UID}, ${GID}) |
--env-file .env.deploy flag — resolved at docker compose up time, not passed to the container |
# 1. Copy runtime env file (for container environment)
cp .env.example .env
# Edit .env with your real Telegram token, LLM credentials, and authorization config
# 2. Copy Compose interpolation file (for host file ownership)
cp .env.deploy.example .env.deploy
# Edit .env.deploy UID/GID if your host user is not 1000:1000
# 3. Start with both files — one for interpolation, one for the container
# Compose automatically picks up env_file: .env defined in docker-compose.yml
docker compose --env-file .env.deploy up -dThe container:
- Runs as the
UID:GIDspecified in.env.deployso the bind-mounted database is owned by the host user - Persists the SQLite database to
./data/expenses.dbon the host - Reads
AUTHORIZED_USERS_CONFIG_PATHfrom.envat runtime. In.env, setAUTHORIZED_USERS_CONFIG_PATH=/data/authorized-users.jsonand place the whitelist file in./data/on the host. (The default from.env.exampleis a local path — Docker needs the container path.) - Optionally set
UNAUTHORIZED_LOG_PATH=/data/unauthorized.login.envfor a dedicated audit log inside the same persisted volume. - Auto-restarts unless explicitly stopped (
restart: unless-stopped)
Managing the bot:
docker compose --env-file .env.deploy logs -f # follow logs
docker compose --env-file .env.deploy down # stop and remove
docker compose --env-file .env.deploy up -d # restart
docker compose --env-file .env.deploy build # rebuild after code changes# Unit/integration tests (pytest)
uv run pytest
# BDD acceptance tests (Behave)
uv run behavePre-commit runs gitleaks on every commit to block accidental secret leaks.
uv run pre-commit install # one-time setup
uv run pre-commit run --all-files # test it on the whole repoConfiguration: .pre-commit-config.yaml (framework), .gitleaks.toml (allowlist).
uv run ruff format # code formatting
uv run ruff check # linting
uv run ty check # type checking
uv run pytest # unit/integration tests
uv run behave # BDD scenariosThe project follows a hexagonal (ports & adapters) architecture:
src/expense_report/
├── domain/ # Entities, value objects, domain services
│ ├── models.py # Expense, ExtractionResult (frozen dataclasses)
│ ├── correction_state.py # PendingCorrection, CorrectionStore
│ └── csv_generator.py # Monthly expense CSV generation
├── ports/ # Interface definitions (Protocols/ABCs)
│ ├── extraction.py # ExtractionPort (extract + refine)
│ └── repository.py # ExpenseRepositoryPort (save, get, query)
└── adapters/
├── inbound/ # Driving adapters (entry points)
│ ├── main.py # Telegram bot (python-telegram-bot)
│ ├── telegram_bot.py # Bot handlers
│ └── cli_extraction.py # CLI extraction commands
└── out/ # Driven adapters (external services)
├── dspy_extraction.py # LLM extraction via dSPy
└── sqlite_repository.py # SQLite persistence
- Domain has zero framework/IO imports — frozen dataclasses only
- dSPy backend: Uses
ChainOfThoughtwith anExpenseSignaturefor structured LLM output - SQLite: Zero-config persistence,
Decimalvalues stored as strings for precision - Correction loop: Up to 3 refinement attempts via the same
ExtractionPort.refineinterface - See
docs/adr/for full Architecture Decision Records
| Package | Purpose |
|---|---|
dspy-ai |
LLM extraction framework |
python-telegram-bot[job-queue] |
Telegram Bot API client |
openai |
OpenAI-compatible LLM backend |
pillow |
Image processing for receipt photos |
| # | Story | Status |
|---|---|---|
| 1 | Extract expense data from receipt photo | ✅ |
| 2 | Extract expense data from free-text message | ✅ |
| 3 | Send receipt photo via Telegram and get confirmation | ✅ |
| 4 | Send free-text expense via Telegram and get confirmation | ✅ |
| 5 | Extraction fails gracefully on unreadable receipt | ✅ |
| 6 | Bot prompts for missing fields on partial extraction | ✅ |
| 7 | User can correct/amend extracted fields | ✅ |
| 8 | Multiple correction retries with max attempt limit | ✅ |
| 9 | Generate monthly CSV expense report | ✅ |
| 10 | Expenses are isolated per user | ✅ |
| 11 | User authorization whitelist | ✅ |
MIT
