Skip to content

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
Hold "Alt" / "Option" to enable pan & zoom

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:

  1. User sends /add_keyword
  2. Bot replies: "OK. Send me the keyword you want to watch for."
  3. User types the keyword
  4. Bot confirms: "✅ Now watching for: 'bose soundlink'"

Send /cancel at any point to abort.

Removal Flow

All removal actions require confirmation:

  1. User taps an item from the inline keyboard
  2. Bot replies: "Remove 'laptop'?" with Yes / Cancel buttons
  3. Yes → item deleted, confirmation message sent
  4. 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:

  1. Telegram long-polling — handles all bot commands and callback queries
  2. Woot poll loop — fetches feeds, matches keywords, sends notifications every 15 min
  3. 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 = 1 per 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:

woot-deal-bot = {
  create_password = true
  databases = {
    woot-deal-bot = {}
  }
}

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

python -m pytest
  • tests/test_matcher.py — fuzzy score correctness, threshold boundary behavior
  • tests/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.