A Telegram bot for UK Deliveroo riders. Send a screenshot of an order offer, get back an accept / borderline / skip verdict grounded in the real cycling time from your current location through every pickup and dropoff.
Local-first: OCR + a local VLM read the screenshot, Mapbox computes the cycling route, SQLite stores everything for later analysis.
When a Deliveroo order offer pops up, a rider gets a few seconds to accept or reject — while sitting on a bike, often mid-ride. The offer screen shows the payout and the addresses, and nothing else: no distance, no time estimate, no hint that the "£5.53, two drops" job is actually a 90-minute slog across town that nets less than £4/hour.
Riders develop gut feel, but gut feel fails exactly where it matters most: stacked orders with multiple pickups and drops, unfamiliar postcodes, and peak-hour traps where restaurant waits eat the margin. Every bad accept locks you out of better orders for the next half hour or more.
This project closes that information gap in the only window that matters — the decision moment:
- Screenshot the offer, send it to the bot. OCR (plus a local vision model as fallback) extracts the payout and every pickup/dropoff address.
- The bot computes the real trip. Mapbox routes the actual cycling path from where you are right now, through every stop, and adds realistic per-stop and peak-hour friction.
- You get a verdict in seconds — accept / borderline / skip — with the effective £/hour and a per-leg time breakdown, so you can sanity-check it at a glance.
Beyond the instant verdict, every order, route, outcome, and post-delivery
survey answer is logged. Over time that becomes a personal dataset of which
restaurants make you wait, which buildings swallow ten hidden minutes, and
which areas actually pay — feeding a /suggest command that recommends
where to position for better orders, and an operator dashboard for digging
into the data.
Everything runs locally on your own machine: screenshots never leave it, the only cloud calls are routing lookups. No accounts, no SaaS, no fees.
See PROJECT_PLAN.md for the architecture and design decisions.
# 1. clone, install deps. --extra mlx pulls mlx-vlm + torch + timm.
uv sync --extra mlx
# 2. copy .env template and fill in tokens
cp .env.example .env
$EDITOR .env
# TELEGRAM_BOT_TOKEN=... (from @BotFather)
# MAPBOX_TOKEN=... (from mapbox.com/account/access-tokens)
# VISION_PROVIDER=mlx_vlm
# MLX_VLM_MODEL=/abs/path/to/qwen7b (or an mlx-community repo id)
# 3. run the bot in the foreground for testing
uv run python -m rider_bot
# 4. (separately) run the dashboard
uv run python -m dashboard # http://localhost:8000
# 4. or run in the background with auto-restart on rerun
./scripts/run.shscripts/run.sh launches and supervises both the bot and the dashboard
as native macOS processes (no Docker). Each writes a PID file under
data/ and a daily-rotating log under data/logs/.
./scripts/run.sh # start both bot + dashboard
./scripts/run.sh bot # start bot only
./scripts/run.sh dashboard # start dashboard only (http://localhost:8000)
./scripts/run.sh status # what's running
./scripts/run.sh stop # stop bothRe-running start for a service stops the existing instance first, so
a single command is enough to "restart". Logs:
data/logs/bot.logdata/logs/dashboard.log
Read-only operator dashboard at http://localhost:8000. Built from the "RiderIntel" design handoff:
- OPERATE — Overview · Live feed · Riders
- ANALYSE — Spatial · Surveys · Decisions
- SYSTEM — Pipeline
The dashboard renders the React/Babel prototype as-is; the FastAPI server
injects a window.RI payload built from the bot's SQLite DB on each page
load. /api/data returns the same JSON for clients that want it raw.
Run standalone (no Docker, useful for fast frontend iteration):
uv run python -m dashboard| Var | Default | Notes |
|---|---|---|
TELEGRAM_BOT_TOKEN |
— | Required. From @BotFather. |
MAPBOX_TOKEN |
— | Required if ROUTING_PROVIDER=mapbox. |
GOOGLE_MAPS_API_KEY |
— | Required if ROUTING_PROVIDER=google. |
ROUTING_PROVIDER |
mapbox |
mapbox or google. Same provider used for geocoding. |
PARSER |
hybrid |
ocr, vlm, or hybrid (OCR primary, VLM on low confidence). |
VISION_PROVIDER |
ollama |
mlx_vlm, ollama, or anthropic. |
MLX_VLM_MODEL |
mlx-community/Qwen2.5-VL-3B-Instruct-4bit |
Apple Silicon only. |
OLLAMA_MODEL |
qwen3-vl:4b |
Only if vision provider is ollama. |
OCR_CONFIDENCE_FLOOR |
0.7 |
Below this, hybrid falls back to the VLM. |
LOG_LEVEL |
INFO |
SQLite at data/bot.db. Tables:
user— one row per Telegram user, with first-seen / last-seenuserlocation— every shared location, full GPS precisionorder— every screenshot, with parsed fields, route, verdict, timingsfeedback— 👍/👎 on the verdict itselfdeliveryoutcome— did the rider accept, complete, was the time right, any issuespostcodecache— postcode → lat/lng cache (never re-geocoded)rawevent— every Telegram update raw JSON, append-only, for forensic replay
/forget from inside Telegram wipes all of the above for the sender.
| Command | What |
|---|---|
/start |
Welcome + workflow hint |
/help |
List commands |
/stats |
Your last 7 days |
/forget |
Delete all your stored data |
Anything else (photo, location share, button tap) is handled automatically.
- stdout: streaming JSON (every event), readable by
tail -f data/logs/run.log - data/logs/bot.log: same content, rotated daily, 14 days kept
- The bot also stores raw Telegram updates in the
raweventtable — those are the structured equivalent and the place to query for analytics
src/rider_bot/
├── __main__.py — entry point
├── bot.py — Telegram handlers
├── parser/ — OCR + VLM parsers (mlx_vlm, ollama, anthropic) + hybrid
├── routing.py — Mapbox + Google routing clients
├── geocoding.py — Mapbox + Google geocoding clients
├── scoring.py — verdict logic + card formatter
├── models.py — SQLModel tables
├── db.py — session + helpers
├── survey.py — 23-question post-delivery survey catalogue
└── prompts.py — VLM prompt + Anthropic tool schema
dashboard/
├── __main__.py — uvicorn entry point
├── server.py — FastAPI: /api/data + index.html with injected payload
└── static/ — React/Babel prototype (RiderIntel design handoff)
tests/ — unit tests (scoring)
scripts/ — utilities
├── run.sh — background launcher with PID file
├── test_ocr.py — OCR-only smoke test
├── test_pipeline.py — full parse→geocode→route→score on fixtures
└── benchmark_parsers.py — (planned)
data/
├── bot.db — SQLite, gitignored
├── screenshots/ — saved images, gitignored
└── logs/ — rotating daily logs, gitignored
uv run pytest # unit tests
uv run python scripts/test_pipeline.py # full pipeline on fixtures
uv run python -m rider_bot # run the bot