Reads one WhatsApp Web thread on a timer, extracts action items with a local Ollama model, and files them as tasks in a Notion database. Runs unattended as a Windows Scheduled Task. No cloud services beyond Notion, no API keys for WhatsApp, nothing leaves the machine except the finished task.
-
Create the virtual environment and install dependencies:
& "C:\Users\Arin\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe" -m venv ".venv" .\.venv\Scripts\python.exe -m pip install -r requirements.txt .\.venv\Scripts\python.exe -m playwright install chromium
-
Copy
config.example.jsontoconfig.jsonand fill it in. -
Provide the Notion token. It is resolved in this order:
NOTION_TOKENenvironment variable- a file, by default
%LOCALAPPDATA%\whatsapp_task_tracker\notion_token.txt(override withnotion.token_file) notion.tokeninconfig.json
Use one of the first two. This folder sits inside a cloud-synced vault, so a token written into
config.jsonis replicated to the cloud (see Security). -
Log in to WhatsApp Web once. This writes a persistent browser profile that subsequent headless runs reuse:
.\.venv\Scripts\python.exe tracker.py login -
Verify without writing to Notion:
.\.venv\Scripts\python.exe tracker.py run-once --headed --dry-run
-
Run the persistent loop (this is what the Scheduled Task invokes):
.\.venv\Scripts\pythonw.exe tracker.py loop
| Key | Default | What it does |
|---|---|---|
whatsapp.phone |
— | Thread to watch, digits only, country code included |
whatsapp.poll_minutes |
25 | Gap between read cycles |
whatsapp.active_hours |
08:00–23:00 | Cycles only run inside this window. A window that wraps past midnight (22:00–06:00) is supported |
whatsapp.first_read_timeout_ms |
900000 | Budget for the first read after opening a browser. WhatsApp Web resyncs chat history on every fresh connection and that takes minutes, not seconds |
whatsapp.read_timeout_ms |
60000 | Budget for steady-state reads once the session is warm |
whatsapp.reload_after_minutes |
60 | Reload the page if the process heartbeat is older than this, i.e. the machine slept |
whatsapp.reopen_after_failures |
3 | Consecutive unclassified failures before forcing a fresh browser |
whatsapp.alert_after_hours |
6 | Hours without a successful read before the tracker files a Notion task about its own outage |
whatsapp.profile_dir |
%LOCALAPPDATA%\whatsapp_task_tracker\browser-profile |
Chromium profile location. Keep it off any cloud-synced path |
ollama.timeout_seconds |
180 | Extraction request timeout |
- Cold-start resync. A fresh browser cannot render any message until
WhatsApp finishes downloading history. That state is detected explicitly and
reported as
syncing, not as an error, and it keeps the same browser and the long timeout rather than restarting the sync from zero. - Reload loops. Page reloads key off a heartbeat written every cycle, not off the last successful run. Keying it off success meant a run of failures forced a reload every cycle, restarting the multi-minute resync before it could ever complete — a failure that sustained itself indefinitely.
- Dead browser. Playwright reports a dead page through several different
message shapes. Any of them raise
BrowserGone, which reopens the browser instead of polling a page that no longer exists. A consecutive-failure counter forces the same reopen for anything that could not be classified. - Duplicate tasks. Seen messages are tracked as a bounded set of message keys, not a single checkpoint pointer. WhatsApp virtualises the message list, so a single checkpoint message is regularly absent from the DOM; the old fallback re-read the last 20 messages and re-filed tasks it had already filed.
- Lost tasks. A failed Notion write is queued to
state/pending_tasks.jsonand retried at the start of every later cycle, because by the time it fails the source messages are already marked as processed. - Two instances at once. An OS-level file lock stops a second process sharing the Chromium profile directory, which otherwise corrupts the profile and takes the WhatsApp session with it. The lock releases automatically if the process is killed, so there is no stale lock to clear by hand.
- Corrupt state. Every state file is written to a temp file and then replaced, so a crash mid-write leaves the previous version intact. Unreadable state files fall back to a default instead of raising.
- Console encoding. Message text contains emoji and Devanagari; Windows consoles default to cp1252 and reject both. All console output goes through a helper that cannot raise, and stdout is reconfigured to UTF-8 at startup.
- Silent death. This is the one failure the code cannot fix by itself, so it
reports instead: after
alert_after_hourswithout a successful read, it files a Notion task saying it is down. It already holds Notion credentials, and that board is somewhere actually read, unlike a local log. One task per outage, cleared on the next success. Added because the tracker failed silently for four days and nothing surfaced it. - A model that misfires. Extraction output is capped at 20 tasks per cycle, so a confused or prompt-injected response cannot flood the database.
- A model that was never pulled. Checked against
/api/tagsand reported by name, rather than surfacing as a bare HTTP 404 from Ollama. - Disk growth.
tracker.logrotates at 2 MB, keeping one backup. History is capped at 60 entries.
# status at a glance
.\.venv\Scripts\python.exe dashboard.py # http://127.0.0.1:8765
# tests (no framework, no network)
.\.venv\Scripts\python.exe test_tracker.py
.\.venv\Scripts\python.exe test_dashboard_escaping.py
# scheduled task
Get-ScheduledTask -TaskName 'WhatsApp Task Tracker'
Start-ScheduledTask -TaskName 'WhatsApp Task Tracker'
Stop-ScheduledTask -TaskName 'WhatsApp Task Tracker'Stop the Scheduled Task before running login or run-once, or the instance
lock will refuse to start a second browser against the same profile.
This repository is public. config.json and state/ are gitignored and have
never been committed; no token appears anywhere in git history.
Two things are deliberately kept outside this folder, because it sits inside a cloud-synced Obsidian vault and everything in it replicates to the cloud:
- The Chromium profile (
%LOCALAPPDATA%\whatsapp_task_tracker\browser-profile). It holds an authenticated WhatsApp session, which is a credential. A sync client writing to a live browser profile also corrupts it, so this is a reliability fix as much as a security one. - The Notion token (
%LOCALAPPDATA%\whatsapp_task_tracker\notion_token.txt, orNOTION_TOKENin the environment).notion.tokeninconfig.jsonstill works but should be left empty.
state/ does still live in the synced folder and pending_tasks.json can hold
message-derived text, so it is chat content going to the cloud. Lower stakes
than a credential, but worth knowing. Moving it would break the companion
check-in assistant that reads state/last_run.txt.
- Message text is treated strictly as data. The extraction prompt says so explicitly, model output is validated before use, and the dashboard escapes stored log content before rendering it.
| Path | Purpose |
|---|---|
tracker.py |
Read, extract, file. All three commands live here |
dashboard.py |
Read-only status page bound to localhost |
config.json |
Local secrets and runtime config, gitignored |
state/processed.json |
Bounded set of already-handled message keys |
state/pending_tasks.json |
Notion writes awaiting retry |
state/heartbeat.txt |
Written every cycle; drives reload decisions |
state/last_run.txt |
Written on a successful read only |
state/status.json |
Current status snapshot for the dashboard |
state/outage_alert.txt |
Present while an outage has already been reported to Notion |
state/tracker.log |
Rotating run log |
test_tracker.py |
Self-checks for the logic whose failures are silent |