Skip to content
All projects

spillpromo.id

A curated Shopee deal feed run from Telegram, with an LLM in the loop

The problem

Deal channels on Telegram and WhatsApp are run by hand: someone copies a product link, rewrites the caption, checks the price, posts it in three places, and later forgets to mark it as expired. I wanted the same output with one action from the admin, and I wanted every link to be attributable to the channel it was clicked from.

What I built

A single repo with two entry points, deployed as two Docker services:

  • worker (exactly one replica): a Telegram bot on long-polling, a serial job queue on Postgres, the LLM enrichment step, and a handful of cron jobs (expiry, retention, daily recap, reminders).
  • web (stateless, Astro SSR): the public catalogue, article pages, /go/{id} redirects that record clicks, and a small JSON API for external agents.

The flow for one deal: admin pastes a Shopee link (or a photo with a caption) → the resolver follows the redirect chain and extracts the canonical shop_id/item_id → a dedup check against the last 48 hours → the LLM turns the pasted text into a title, category, price pair, highlight points and a short caption → validation → if everything checks out the deal is published automatically to the web, the Telegram channel, and a ready-to-paste WhatsApp text. If anything is off, the admin gets a card with Approve / Edit / Reject buttons instead.

Decisions worth explaining

The LLM is not allowed to produce URLs or percentages. Every URL comes from the resolver, and the discount percentage is a generated column in Postgres. The model only does what it is good at: reading messy promo text and returning structured JSON, validated with Zod. If the output is invalid it gets one retry; if it still fails, the deal is kept with a fallback title and flagged for review rather than dropped.

Resolve from the redirect chain, not the final page. Shopee short links go through several hops and end on a JavaScript-heavy, anti-bot page. The resolver reads each Location header and stops as soon as a product ID appears, so most links are resolved in one or two requests without ever fetching the product page. It also knows that the short-link host answers mobile clients with an interstitial and desktop clients with a 301, and falls back accordingly.

Honest expiry dates. A deal only shows “valid until” when the date came from the promo text or from the admin. Deals with a default internal expiry simply drop off the homepage later and are labelled “older promo, price may have changed” instead of “expired”.

Untrusted text stays data. Pasted promo text is wrapped in a <source> tag and the prompt states it is content, not instructions. The test suite includes adversarial messages.

Attribution per channel and per deal. Every public link goes through /go/{short_id}?ch=web|telegram|whatsapp|blog, which records the click before redirecting. Affiliate sub-IDs follow the same scheme, so conversions can be traced back to a single post.

Beyond deals

The same bot accepts promo announcements without a product link (campaign schedules, voucher codes, restaurant discounts). They become a separate entity with their own schedule logic: the LLM suggests start and end times, the code decides, and a cron job posts “starts in 1 hour” and “ends in 3 hours” reminders to the channel at most once each.

An article layer sits on top: external AI agents can read a feed of deals that still need an article (GET /api/deals) and submit Markdown drafts through an authenticated webhook. Outbound links in those drafts are stripped on the way in. Drafts arrive in the admin’s chat with Publish / Archive buttons; a nightly recap article is generated from data with only the intro and outro written by the model.

Operating it

Everything runs on servers I manage (see the infrastructure case study). Failures are designed to be boring: Telegram 429s honour retry_after, the resolver retries with exponential backoff only on 5xx and timeouts, and the worker exposes a health endpoint that reports unhealthy if the polling loop stops ticking. The admin never has to type commands; the bot registers its command menu, a persistent keyboard, and an edit wizard driven by inline buttons.