A data pipeline to track and report on the ELO progress of members of the League of Naija group chat using Python, PostgreSQL, node.js, and WhatsApp Web API.
On launch, members of the gc were able to type commands which generated a table of elo changes for each member of the group chat who had registered in a Google form I shared with them. My machine is prod.
The pipeline runs hourly via cron or systemd timer, fetching player data from Google Forms, generating PUUIDs, checking current ELO ratings, and reporting changes to the WhatsApp group.
Before running the bot, ensure you have the following installed:
-
Docker and Docker Compose
- Install Docker Desktop from here
- Ensure Docker Compose is included in your installation
-
Google API Credentials
- Create a Google Cloud project
- Enable Google Forms API and Google Sheets API
- Create credentials (OAuth 2.0 Client ID)
- Download credentials JSON file, rename it to credentials.json and place it in the .google directory.
-
Riot Games API Key
- Register at Riot Games Developer Portal
- Generate a developer API key, rename it to riot_api_key and place it in the .env directory.
-
WhatsApp Group ID
- Open your WhatsApp group of choice on WhatsApp Web
- Inspect the page elements of the group chat
- Copy the group ID from the HTML element with the class name 'chat-title'
- Declare it as whatsapp_group_id in the .env directory.
- Create a
.envfile in the config directory with the following variables:
# Google API Credentials
GOOGLE_APPLICATION_CREDENTIALS=path/to/credentials.json
GOOGLE_FORM_ID=your_form_id
GOOGLE_SHEET_ID=your_sheet_id
# Riot Games API
RIOT_API_KEY=your_riot_api_key
RIOT_REGION=na1
# WhatsApp
WHATSAPP_GROUP_ID=your_group_id
# Announce the command list to the group on startup. Off by default -- the bot
# restarts on every crash, and one announcement per restart is noise.
ANNOUNCE_ON_START=false
elo_snitch_bot/
├── assets/ # Static assets
├── config/ # Configuration files
├── data/ # Data storage directory
│ └── elo_changes/ # ELO change history
├── docker/ # Docker configuration
├── logs/ # Application logs
├── node_modules/ # JavaScript dependencies
├── sql/migrations/ # Ordered, re-runnable schema migrations
├── src/
│ ├── python/ # Python source code
│ │ ├── run_pipeline.py # Pipeline orchestrator (replaces Airflow)
│ │ ├── fetch_google_forms_data.py # Fetch player data
│ │ ├── generate_puuid.py # Player PUUID generation
│ │ ├── elo_check.py # ELO checking
│ │ └── elo_tracker.py # ELO tracking and reporting
│ └── js/ # WhatsApp bot
│ ├── bot.js # Client wiring and event handlers
│ ├── commands.js # Command table and rate limiting
│ ├── format.js # Report formatting (pure, tested)
│ └── data.js # Reads the pipeline's latest.json
├── .env # Environment variables (repo root, for docker-compose)
├── Dockerfile # Docker configuration
└── docker-compose.yaml # Docker Compose configuration
- Clone the repository
- Create and configure your
.envfile as described above - Start Postgres:
docker compose up -d pgdatabaseIf port 5432 is already taken by another project, set POSTGRES_PORT in a
.env at the repo root (for compose) and in config/.env (for the
Python client) so the two agree:
POSTGRES_PORT=5433 docker compose up -d pgdatabase- Apply database migrations, in order:
docker exec -i <postgres-container> psql -U root -d snitch_bot_db \
-v ON_ERROR_STOP=1 < sql/migrations/001_consolidate_players.sqlThen sanity-check the result — every count in the output should show zero orphans before you rely on it:
docker exec -i <postgres-container> psql -U root -d snitch_bot_db \
< sql/migrations/001_verify.sql- Start the WhatsApp bot (from the repo root) — see WhatsApp Bot for the first-run QR pairing:
npm install && npm startPlayers live in a single players table keyed on their Riot ID
(summ_id + player_tag). Earlier versions keyed players on the row index of
the Google Sheet, which meant deleting or reordering a sheet row silently
reassigned that player's entire ELO history to someone else. 001 migrates off
that scheme; players.legacy_id retains the old index for auditing only.
npm install
npm startOn first run the terminal prints a QR code. Open WhatsApp on your phone →
Settings → Linked devices → Link a device → scan it. The session is saved to
src/js/.wwebjs_auth/, so later starts skip the QR entirely. Delete that
directory to force a re-pair.
Once linked the bot prints Listening in "<group name>". If instead it prints
that no group matches WHATSAPP_GROUP_ID, it lists every group it can see with
their IDs — copy the right one into config/.env.
Recognised only in the group named by WHATSAPP_GROUP_ID, and only when the
message is exactly the command. Anything else is ignored in silence, including
in direct messages.
| Command | Reports |
|---|---|
!elocheck |
Every tracked player's ELO change, grouped by queue |
!topelo |
The five largest ELO changes |
!winrate |
Solo/duo win rates, most active players, and totals |
!help |
The command list |
Each report ends with when the pipeline last ran, in both absolute and relative
form: _Last updated: 2026/08/08 21:32:50 (3 hours ago)_. Reports read
data/*/latest.json, so the bot has nothing to show until the pipeline has run
at least once.
Commands are rate limited to 5 per user per minute. Exceeding it is ignored silently rather than answered, so a flood is not amplified into a reply per message.
npm testNode's built-in runner (node --test) — no test framework to install. The suite
covers command parsing, timestamp handling, the three formatters, and the rate
limiter. bot.js is deliberately untested: it is I/O only, and everything worth
asserting was moved out of it.
The bot runs hourly (via cron or systemd timer) and executes the following tasks in sequence:
fetch_google_forms_data.py- Fetch player data from Google Formsgenerate_puuid.py- Generate PUUIDs for playerselo_check.py- Check current ELO for all playerselo_tracker.py- Track and report ELO changes
The pipeline is orchestrated by src/python/run_pipeline.py. Choose one of the following to run it hourly:
Add to your crontab (crontab -e):
0 * * * * cd /path/to/elo_snitch_bot && python -m src.python.run_pipeline >> logs/pipeline.log 2>&1This runs at the top of every hour (minute 0). Adjust the minute field (first 0) if you prefer a different time within the hour.
Create /etc/systemd/system/elo-snitch.service:
[Unit]
Description=ELO Snitch Bot Pipeline
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=oneshot
User=youruser
WorkingDirectory=/path/to/elo_snitch_bot
ExecStart=/usr/bin/python -m src.python.run_pipeline
StandardOutput=journal
StandardError=journalCreate /etc/systemd/system/elo-snitch.timer:
[Unit]
Description=Run ELO Snitch Pipeline Hourly
Requires=elo-snitch.service
[Timer]
OnBootSec=5min
OnUnitActiveSec=1h
Persistent=true
[Install]
WantedBy=timers.targetEnable and start:
sudo systemctl daemon-reload
sudo systemctl enable --now elo-snitch.timerMonitor:
sudo systemctl status elo-snitch.timer
sudo journalctl -u elo-snitch.service -fOne machine runs both the bot and the pipeline. They are coupled through the
filesystem -- the pipeline writes data/<folder>/latest.json, data.js reads it
-- so separating them would mean a second machine, a second volume and some way
to share files between them.
The machine cannot scale to zero. WhatsApp Web is an outbound websocket, so there is no inbound request for Fly to wake a stopped machine on; if it stops, the group loses the bot until you notice.
Roughly $3.35/mo at the time of writing: ~$3.20 for an always-on
shared-cpu-1x 512MB machine, $0.15 for a 1GB volume, $0 for Postgres on a free
tier. Fly's plan minimum is higher than that, so it is likely what you actually
pay. Check current prices before committing.
Postgres first. Create a free database (Neon, Supabase, or similar), then apply the migrations to it from your machine:
psql "$NEON_URL" -f sql/migrations/001_consolidate_players.sql
psql "$NEON_URL" -f sql/migrations/002_normalize_and_merge_players.sqlThen create the app and its volume. Pick a region near you -- lhr is the
default in fly.toml:
fly launch --no-deploy --copy-config --name elo-snitch-bot
fly volumes create snitch_data --size 1 --region lhrSecrets. The Google service-account JSON is base64-encoded because Fly secrets are single-line:
fly secrets set \
DATABASE_URL="postgresql://..." \
WHATSAPP_GROUP_ID="1234567890@g.us" \
RIOT_API_KEY="RGAPI-..." \
RIOT_REGION="europe" \
RIOT_PLATFORM="euw1" \
GOOGLE_SHEET_ID="..." \
GOOGLE_SHEET_RANGE="Form Responses 1!A:D" \
GOOGLE_CREDENTIALS_B64="$(base64 -w0 .google/credentials.json)"On Windows PowerShell the last one is:
[Convert]::ToBase64String([IO.File]::ReadAllBytes(".google\credentials.json"))fly deploy
fly logsThe bot has no session yet, so it prints a QR code to the log. Scan it with WhatsApp on your phone (Settings -> Linked Devices). The code refreshes every 20 seconds, so a missed one is not a problem.
If log streaming mangles the QR into unscannable noise, set
LOG_QR_PAYLOAD=true as a secret, redeploy, and paste the logged payload into
any QR renderer. Unset it once you are linked -- that string links a device
to your WhatsApp account, and log output has a way of ending up in chats.
The session persists on the volume, so subsequent deploys do not re-prompt.
fly logs |
bot and pipeline output, interleaved |
fly ssh console |
shell on the machine; snapshots are under /data/snapshots |
fly status |
machine state and restart count |
fly secrets set RIOT_API_KEY=... |
rotate the key; restarts the machine |
A Riot development key expires every 24 hours. The pipeline will start failing a day after each rotation. That is survivable by design -- failures are logged and swallowed, and the bot keeps answering from the last good snapshot -- but the numbers go stale until you set a fresh key. Apply for a personal or production key if this is meant to run unattended.
Set RUN_PIPELINE=false to deploy the bot alone, serving whatever is already on
the volume.
Two .env files, deliberately separate:
| File | Read by | Purpose |
|---|---|---|
.env (repo root) |
docker compose only | host port substitution in docker-compose.yaml |
config/.env |
the Python pipeline | Riot/Google credentials, DB connection |
POSTGRES_PORT must be set to the same value in both, or the pipeline will
connect to a different database than the one compose published.
Defaults are POSTGRES_PORT=5432 and PGADMIN_PORT=5051. Override in the root
.env when another project already binds those.
Browse to http://localhost:${PGADMIN_PORT} and log in with
PGADMIN_DEFAULT_EMAIL / PGADMIN_DEFAULT_PASSWORD from config/pgadmin.env.
The snitch_bot server is preregistered from config/pgadmin_servers.json.
Expanding it prompts once for the Postgres password (POSTGRES_USER's password,
root by default) — tick Save Password to be asked only once.
Note that pgAdmin reads only PGADMIN_* variables. The DB_HOST/DB_PORT/
DB_USER/DB_PASS/DB_NAME entries in config/pgadmin.env are inert; the
connection is defined in pgadmin_servers.json instead. That file uses the
compose service name pgdatabase and its internal port 5432, not the
published host port.
pip install -r config/requirements.dev.txt
pytestThe suite covers the ranked-ladder maths, which has no database or network
dependency. ladder_points is verified exhaustively — every reachable rank
from Iron IV 0 LP to Challenger is enumerated and asserted strictly increasing —
rather than by sampled examples.
Riot's league_points resets to near zero on promotion, so subtracting two raw
values reports a tier climb as a large loss. Gold IV 98 LP → Platinum IV 4 LP
came out as -94 LP while the player had in fact gained 306. Because
get_top_changes then ranked by absolute LP, the leaderboard was dominated by
promotions masquerading as the worst losses of the day — the headline output was
wrong precisely when something worth reporting had happened.
ladder_points maps a rank onto one monotonic scale
(tier * 400 + division * 100 + lp) before differencing, so the sign always
agrees with the direction the player actually moved.
- If you encounter Google API authentication issues, verify your credentials in the
.envfile - For Riot API rate limiting issues, consider implementing a retry mechanism or increasing the delay between API calls
- Check Docker logs for detailed error messages:
docker-compose logs