woot-deal-bot¶
woot-deal-bot is a multi-tenant Telegram bot that watches Woot.com for deals matching user-defined keywords and sends rich notifications when a match is found. Each Telegram chat (identified by its chat ID) is an independent tenant with its own keywords and feed subscriptions.
How It Works¶
graph LR
Woot[Woot API] -->|poll feeds| Poller[woot-deal-bot poller]
Poller -->|fuzzy match| Matcher[rapidfuzz]
Matcher -->|new match| Notifier[Telegram notification]
Notifier --> User[Telegram chat]
User -->|bot commands| Bot[Telegram bot handlers]
Bot --> DB[(PostgreSQL)]
Poller --> DB
Digest[Daily Digest 12:01 AM CT] -->|Featured feed| User
Every 15 minutes the poller fetches all Woot feed categories subscribed to by at least one tenant, deduplicating requests across tenants. For each deal not yet seen by a tenant, it runs fuzzy keyword matching using rapidfuzz. On a match above the configured threshold, a rich notification is sent and the offer is recorded as seen for that tenant.
A separate daily digest fires at 12:01 AM CT and sends all Featured deals as photo cards to opted-in tenants.
Deployment¶
-
Namespace
bots -
Source
gitea.hdhomelab.com/cicd/woot-deal-bot -
Config
flux/apps/noah/bots/woot-deal-bot/ -
Port
8080(liveness probe only, cluster-internal)
Telegram Commands¶
| Command | Description |
|---|---|
/start |
Register the current chat as a tenant |
/add_keyword |
Interactive: bot prompts for keyword, user types it, bot confirms |
/remove_keyword |
Interactive inline keyboard to select and remove a keyword (with confirmation) |
/list |
Show active keywords and subscribed feed categories |
/add_feed |
Interactive inline keyboard of unsubscribed Woot feed categories to subscribe to |
/remove_feed |
Interactive inline keyboard of subscribed feeds to unsubscribe from (with confirmation) |
/feeds |
List all available Woot feed category names |
/daily_digest |
Toggle daily Deal of the Day digest (fires at 12:01 AM CT) |
/test_digest |
Send today's Featured deals immediately (for testing) |
/test_poll |
Run a keyword poll cycle immediately, skipping deduplication |
/api_usage |
Show today's Woot API request count and quota usage |
Add Keyword Flow¶
/add_keyword uses a conversation handler:
- User sends
/add_keyword - Bot replies: "OK. Send me the keyword you want to watch for."
- User types the keyword
- Bot confirms: "✅ Now watching for: 'bose soundlink'"
Send /cancel at any point to abort.
Removal Flow¶
All removal actions require confirmation:
- User taps an item from the inline keyboard
- Bot replies: "Remove 'laptop'?" with Yes / Cancel buttons
- Yes → item deleted, confirmation message sent
- Cancel → dismissed, no action taken
Notification Format¶
Keyword match notifications and daily digest deals both use the same photo card format:
- Product photo
- Bold title
- Sale price, with strikethrough list price and % off when available
- Time remaining (e.g. "12 hours left to buy")
- Link to Woot
Architecture¶
Single async Python process running three concurrent coroutines via asyncio.gather:
- Telegram long-polling — handles all bot commands and callback queries
- Woot poll loop — fetches feeds, matches keywords, sends notifications every 15 min
- Daily digest loop — sleeps until 12:01 AM CT, sends Featured deals, repeats
A lightweight aiohttp server on port 8080 serves GET /healthz. The liveness probe returns 503 if the poller has not completed a cycle within the last 5 minutes.
On Conflict errors from Telegram (two simultaneous polling sessions), the bot waits 30 seconds and retries with drop_pending_updates=True.
Project Structure¶
woot-deal-bot/
├── main.py # asyncio.gather: telegram polling + woot loop + digest loop + healthz
├── bot/
│ ├── config.py # env vars
│ ├── db.py # asyncpg pool, schema init, all queries
│ ├── woot.py # Woot API client (httpx), key rotation on 429/403
│ ├── matcher.py # rapidfuzz keyword matching
│ ├── notifier.py # format and send Telegram deal messages (HTML)
│ ├── poller.py # periodic loop: fetch → match → notify → mark seen
│ ├── daily_digest.py # daily Featured feed digest at 12:01 AM CT
│ ├── api_monitor.py # API usage tracking and 90% quota alert
│ └── handlers.py # Telegram command + callback query handlers
├── scripts/
│ ├── preview_digest.py # preview daily digest output locally
│ └── register_commands.py # register bot commands with Telegram
├── Dockerfile
├── Jenkinsfile
├── requirements.txt
└── version
Data Model¶
tenants(
chat_id BIGINT PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT now(),
daily_digest BOOLEAN NOT NULL DEFAULT FALSE
)
keywords(
id SERIAL PRIMARY KEY,
chat_id BIGINT REFERENCES tenants,
keyword TEXT NOT NULL,
threshold REAL NOT NULL DEFAULT 0.6,
UNIQUE (chat_id, keyword)
)
feeds(
chat_id BIGINT REFERENCES tenants,
feed_name TEXT NOT NULL,
PRIMARY KEY (chat_id, feed_name)
)
seen_offers(
chat_id BIGINT REFERENCES tenants,
offer_id TEXT NOT NULL,
seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (chat_id, offer_id)
)
api_usage(
usage_date DATE PRIMARY KEY DEFAULT CURRENT_DATE,
request_count INT NOT NULL DEFAULT 0
)
seen_offers rows older than 7 days are pruned on each poll cycle. api_usage accumulates per UTC day and is never pruned (historical record). Schema migrations run automatically on startup via ALTER TABLE ... ADD COLUMN IF NOT EXISTS.
Matching Logic¶
score = rapidfuzz.fuzz.token_set_ratio(deal.title, keyword)
if score >= keyword.threshold * 100:
notify
Default threshold: 0.6 (60). Configurable per keyword. token_set_ratio handles word-order variation and partial matches well (e.g. "laptop gaming" matches "Gaming Laptop 15\"").
Rate Limiting¶
Woot API quota: 1,000 req/day per API key. Design:
- Poll interval: 15 minutes (96 cycles/day)
max_pages = 1per feed (100 items max per feed per cycle)- 10 available feeds × 1 page = 10 requests/cycle max
- Feed fetches are deduplicated across tenants — one HTTP call per unique feed category per cycle regardless of tenant count
- Worst case: all 10 feeds subscribed × 96 cycles = 960 req/day, within the 1,000/day quota
Multiple API keys are supported via WOOT_API_KEYS (comma-separated). On 429 or 403, the bot rotates to the next key. The usage counter and 90% alert apply to the combined quota across all keys.
All feed removed
The All feed was excluded — it spans 2,900 items across 29 pages per cycle, which would exhaust the daily quota alone. Use specific category feeds instead.
Error Handling¶
| Scenario | Behavior |
|---|---|
| Woot API 429 / 403 | Rotate to next API key; if all exhausted, log warning and skip |
| Woot API 5xx / connection error | Log warning, skip feed, retry next cycle |
| Telegram Conflict (two polling sessions) | Wait 30s, clean up, retry with drop_pending_updates=True |
| Telegram send failure | Log error, skip notification for that tenant |
| DB unavailable on startup | Crash (let k8s restart) |
| DB transient error during poll | Log error, skip cycle |
| Unhandled exception in poll loop | Caught at loop level, logged, cycle skipped |
Configuration¶
Environment Variables¶
| Env Var | Source | Description |
|---|---|---|
TELEGRAM_BOT_TOKEN |
Vault secret | Telegram bot token |
WOOT_API_KEYS |
Vault secret | Comma-separated Woot API keys; falls back to WOOT_API_KEY |
WOOT_API_KEY |
Vault secret | Single Woot API key (used if WOOT_API_KEYS not set) |
ALLOWED_CHAT_IDS |
Vault secret | Comma-separated allowed Telegram chat IDs; empty = allow all |
DB_HOST |
Static | PostgreSQL host (192.168.68.7) |
DB_PORT |
Static (default: 5432) |
PostgreSQL port |
DB_NAME |
Static | Database name (woot-deal-bot) |
DB_USER |
Vault secret (ExternalSecret) | PostgreSQL role username |
DB_PASSWORD |
Vault secret (ExternalSecret) | PostgreSQL role password |
POLL_INTERVAL_SECONDS |
Static (default: 900) |
How often to poll Woot feeds |
FUZZY_THRESHOLD |
Static (default: 0.6) |
Default similarity threshold (0–1) |
SEEN_OFFER_TTL_DAYS |
Static (default: 7) |
Days before seen_offers rows are pruned |
API_ALERT_THRESHOLD |
Static (default: 0.9) |
Quota fraction at which to send usage alert |
Vault Secrets¶
Create at path apps/woot-deal-bot in Vault:
| Key | Description |
|---|---|
telegram-token |
Telegram bot token — from @BotFather |
woot-api-keys |
Comma-separated Woot API keys — primary,backup |
allowed-chat-ids |
Comma-separated Telegram chat IDs to allowlist; leave empty to allow all |
DB credentials live at apps/psql/woot-deal-bot (written by OpenTofu — do not create manually).
Database Provisioning¶
Add an entry to tofu/tf-deploy/psql/locals.tf:
Then apply:
cd tofu/tf-deploy/psql
tofu init -backend-config=backend.pg.tfbackend
tofu plan -out plan.out
tofu apply plan.out
Credentials are written to Vault at apps/psql/woot-deal-bot. The ExternalSecret in flux/apps/noah/bots/woot-deal-bot/ maps them into the pod as DB_USER and DB_PASSWORD.
See PostgreSQL Provisioning for the full pattern.
Testing¶
Unit Tests¶
tests/test_matcher.py— fuzzy score correctness, threshold boundary behaviortests/test_notifier.py— message formatting, HTML output, photo vs message routing
CI runs pytest as part of the Jenkinsfile image build pipeline.
Local Testing¶
# Pull secrets from k8s
export TELEGRAM_BOT_TOKEN=$(kubectl get secret woot-deal-bot -n bots -o jsonpath='{.data.telegram-token}' | base64 -d)
export DB_USER=$(kubectl get secret woot-deal-bot-db -n bots -o jsonpath='{.data.username}' | base64 -d)
export DB_PASSWORD=$(kubectl get secret woot-deal-bot-db -n bots -o jsonpath='{.data.password}' | base64 -d)
# Scale down cluster first to avoid Telegram Conflict error
kubectl scale deployment woot-deal-bot -n bots --replicas=0
# Run locally
export WOOT_API_KEYS=<primary>,<backup>
export ALLOWED_CHAT_IDS=<your_chat_id>
export DB_HOST=192.168.68.7
export DB_NAME=woot-deal-bot
python main.py
# Scale back up when done
kubectl scale deployment woot-deal-bot -n bots --replicas=1
Use /test_poll to trigger an immediate keyword match cycle and /test_digest to send the current Featured deals without waiting for midnight.