Checks Google Calendar for today's birthdays, generates personalized wishes via a local Ollama model, and posts them to Slack — runs daily via cron inside a Docker container, with automatic retries, catch-up on missed runs, and failure alerting. On days with no birthdays, a brief "no birthdays today" message is sent as a daily heartbeat.
- Docker & Docker Compose
- Google Cloud project with:
- OAuth2 client credentials (
secret.json) — type "Desktop app" - Calendar API enabled
- OAuth consent screen published (not "Testing" — test tokens expire after 7 days, breaking headless operation)
- OAuth2 client credentials (
- A running Ollama instance on your local network
- A Slack incoming webhook URL
cp .env.example .envEdit .env with your actual values:
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL
OLLAMA_HOST=http://192.168.x.x:11434
OLLAMA_MODEL=llama3.2
mkdir -p credentials
cp /path/to/your/secret.json credentials/secret.jsondocker compose builddocker compose run --rm birthday-bot python main.py --authThis will:
- Print an authorization URL to the terminal
- Open that URL in any browser (your laptop, phone, etc.)
- Sign in and grant calendar read-only access
- Google shows an authorization code — copy it
- Paste the code back into the terminal
The token is saved to credentials/token.json and reused automatically. You only need to do this once.
Full test (fetches birthdays → generates wishes → posts to Slack):
docker compose run --rm birthday-bot python main.py --forceDry run (fetches birthdays → generates wishes → skips Slack):
docker compose run --rm birthday-bot python main.py --dry-rundocker compose up -dTo check logs:
docker compose logs -f birthday-botGoogle Calendar API → calendar_service.py → main.py
↓
Ollama (LAN) ← wish_generator.py ←────┘
↓
Slack Webhook ← slack_notifier.py ←────┘
The bot is designed to never silently miss a day:
| Feature | How it works |
|---|---|
| Startup catch-up | On container start, checks if today's run completed; if not, runs immediately |
| Dual-cron schedule | Cron fires every 4 hours (0 */4 * * *) as a safety net for missed runs |
| Duplicate protection | A .last_success_YYYY-MM-DD marker file prevents re-sending wishes |
| Retry with backoff | Failed runs retry 3× with exponential backoff (30s → 60s → 120s) |
| Failure alerting | Sends a Slack error notification if all retries are exhausted |
Note: The bot catches up on today's missed run only — it does not backfill past days. If the container was down for multiple days, only the current day's birthdays will be processed when it comes back up.
If you trigger a manual run while the container is also running cron:
--force— runs the pipeline regardless of the marker, then writes a new one. Later cron runs that day will see the marker and skip — no duplicate wishes.--dry-run— runs the pipeline but does not write the marker (and doesn't post to Slack). Cron will still fire as normal.
| Flag | Description |
|---|---|
--auth |
Run OAuth2 consent flow only, then exit |
--dry-run |
Bypasses duplicate protection. Fetches birthdays + generates wishes but skips Slack and does not write the success marker. So Cron will still run as normal |
--force |
Bypasses duplicate protection. Runs the full pipeline and posts to Slack, then writes a new success marker. So cron will skip that day's run |
The logs/ directory (bind-mounted from the host) contains two types of files:
| File | Purpose |
|---|---|
birthday.log |
Append-only log of all bot activity (successes, failures, retries). Grows over time across all days. |
.last_success_YYYY-MM-DD |
Empty marker file (0 bytes). Created only after a successful run. Its existence is what prevents duplicate runs for that day. |
The marker and log serve different roles — the log records what happened, the marker records whether today succeeded. The log file exists even when runs fail, so it cannot be used for duplicate protection.
secret.jsonandtoken.jsonare bind-mounted, never baked into the Docker image- Slack webhook URL is passed via environment variable, never hardcoded
.dockerignoreexcludes credentials,.env, and logs from the image.gitignorekeeps secrets out of version control- Google enforces
calendar.readonlyscope server-side — even a leaked token can only read calendar data
If token.json is compromised:
- Go to Google Account → Third-party apps
- Find your app → Remove Access
- Delete
credentials/token.json - Re-run
docker compose run --rm birthday-bot python main.py --auth