A scheduled intelligence digest. It reads what people publicly say about a product and its competitors across Reddit, X, forums and news, runs two independent LLM analysis passes, and mails a two-column brief on a schedule.
Built for and deployed on Canadian payments, monitoring Interac e-Transfer and the products around it. Every example below is from that deployment.
The pattern is: pick a topic, let it watch the public web on a cadence, get a short brief instead of a search session.
- Competitive intelligence. Track what rival products ship, price, and get criticised for. The right-hand column exists for exactly this.
- Voice of customer. Surface the complaints people post in public but never file as support tickets. Holds, limits, fraud and edge cases show up on Reddit long before they reach a roadmap.
- Market and sector research. Point it at a category rather than one product and get a recurring read on where it is moving.
- Founder or analyst coverage of a niche. Replace a manual weekly sweep of the same twenty sources.
- Launch and incident monitoring. Run it more often around a release to catch reaction while it still matters.
What repoints cleanly, and what does not. The search queries and all six model prompts are configuration, in prompts.json and prompts/*.md, so changing what is monitored is a JSON and markdown edit. The relevance layer is not: the competitor brand list, the low-insight regexes, the blocked-domain keywords and the subreddit browse pairs are hardcoded to Canadian payments in app.py. Repointing this at a different sector means editing those too. They are the part that makes the output worth reading, so they are worth editing rather than deleting.
Watch the demo on YouTube (https://youtu.be/AN_Qa8JEs7I)
The video walks through a live /email run: scan, analysis, and the digest landing in an inbox.
Every biweekly run pulls from four public sources. No source is a stub.
| Source | How it is reached | Scope |
|---|---|---|
Public search.json plus /new feed browsing |
r/personalfinancecanada, r/canada, r/ontario, r/toronto, r/banking, r/Scams, r/frugalcanada, r/legaladvicecanada, r/CanadianInvestor | |
| X/Twitter | twitterapi.io search, plus DuckDuckGo Twitter results | 4 dedicated e-Transfer queries, run independently of the DDG query list |
| RedFlagDeals | DuckDuckGo site:forums.redflagdeals.com |
Canadian deals and banking forums |
| News and web | DuckDuckGo text and news verticals | Payments press, fintech launches, regulatory items |
Query lists live in prompts.json, not in code: 31 etransfer_queries and 25 competitor_queries. Adding a monitored topic is a JSON edit and a push.
Two things run before the model sees anything:
- A domain blocklist (
_BLOCKED_DOMAINS) drops casino and sportsbook affiliates. They rank well for "e-Transfer" because Canadians use it to fund gambling accounts, and they are pure noise. - A quality score (
_mention_quality_score) ranks each mention on upvotes, engagement, keyword hits, and length. Results are then stratified by platform before the cut, so high-volume Reddit cannot crowd Twitter out of the pool. Biweekly caps are 17 Reddit, 11 Twitter, 7 other.
Previously sent URLs are remembered and filtered out, so consecutive digests do not repeat themselves.
The digest has two columns because the analysis is two independent model calls, dispatched in parallel with asyncio.create_task. Each gets its own corpus and its own prompt file.
| Track | Prompt | Input | Output column |
|---|---|---|---|
| Community chatter | prompts/etransfer_chatter_prompt.md |
Reddit, X, forum posts | Left: real user quotes about fraud, holds, delays, limits |
| Market intelligence | prompts/market_pulse_prompt.md |
Press, competitor mentions | Right: PayPal, Wise, Wealthsimple, KOHO, Revolut, Neo, Apple Pay, Payments Canada |
Splitting them was deliberate. A single call kept letting competitor news bleed into the pain-points column and vice versa. Two calls with narrow corpora and narrow instructions do not have that failure mode.
Prompts are plain markdown, versioned in prompts/. Editing tone or the quality bar does not touch Python. prompts/prompt_recipe.md documents why each rule is there.
A daily job fires at 14:00 UTC (9am Toronto). It self-guards: the biweekly scan only executes if 14 or more days have passed since the last recorded scan. A second daily job runs the quarterly market-trends report on Nov 1, Feb 1, May 1, and Aug 1. All time math uses ZoneInfo("America/Toronto"), so DST is handled rather than approximated.
Delivery is two paths from one scan:
- HTML email to
EMAIL_TOthrough Resend or plain SMTP. 1200px table layout, inline CSS, webmail-safe. - Plain text to every subscribed Telegram chat.
Admins can force a run at any time with /email. If S3-compatible storage is configured, each send uploads the current workbooks and puts download links in the email footer.
pip install -r requirements.txt
export TELEGRAM_TOKEN=... KIMI_API_KEY=...
python app.pyThe bot polls by default. Set WEBHOOK_URL to switch to webhook mode. Send /status in Telegram to confirm which keys and email settings resolved.
prompts.json (31 + 25 queries)
│
▼
fetch_biweekly_mentions()
├── Reddit JSON API search + /new browse across 9 subreddits
├── twitterapi.io 4 dedicated e-Transfer queries
└── DuckDuckGo text + news + Twitter verticals
│
▼
filter: blocklist, SEO-explainer filter, recency (MAX_MENTION_AGE_DAYS, default 120)
score: _mention_quality_score()
route: _classify_channel_and_source()
│
├──────────────────────────┬──────────────────────────┐
▼ ▼ │
=== e-TRANSFER COMMUNITY === === COMPETITOR INTEL === │
=== e-TRANSFER NEWS === ──────────────┘ │
│ │ │
▼ ▼ │
etransfer_chatter_prompt market_pulse_prompt │
│ Kimi call A │ Kimi call B │
└──────────┬───────────────┘ (parallel) │
▼ │
analyze_biweekly() ◄────────────────────────────────┘
│
┌──────────┴──────────┐
▼ ▼
HTML email Telegram
(Resend / SMTP) (plain text, subscribed chats)
│
▼
source_ledger.xlsx + biweekly_reports.xlsx (append-only, one row per mention)
Every mention that reaches the model is logged to biweekly_reports.xlsx with its bucket and inclusion flags, so you can audit why a bullet appeared.
Public:
| Command | Effect |
|---|---|
/start, /help |
Overview and auto-subscribe |
/subscribe, /unsubscribe |
Toggle biweekly broadcasts for this chat |
/status |
Runtime, schedule, and config snapshot |
/scan |
Run the biweekly scan now |
/raw |
Raw mention payload from the last scan |
/prompt |
Query and source config summary |
| plain text | Follow-up question against the latest report |
Admin only (ADMIN_IDS):
| Command | Effect |
|---|---|
/email |
Scan and send the digest immediately |
/quarterly |
Run the quarterly market-trends report |
/smtpcheck |
Validate email provider config and connectivity |
/statefiles |
Download both workbooks from the running host |
/stop |
Cancel active tasks |
Required:
TELEGRAM_TOKEN=<telegram bot token>
KIMI_API_KEY=<moonshot kimi api key>Common optional:
KIMI_API_URL=https://api.moonshot.ai/v1/chat/completions
KIMI_MODEL=kimi-k2.5-preview
TWITTERAPI_IO_KEY=<twitterapi.io key>
ADMIN_IDS=123456789,987654321
MAX_MENTION_AGE_DAYS=120
PORT=3978
WEBHOOK_URL=Email:
EMAIL_ENABLED=1
EMAIL_PROVIDER=smtp # smtp | resend
EMAIL_SEND_MODE=weekly # alert | weekly | always | weekly,alert
EMAIL_FROM=bot@example.com
EMAIL_TO=you@example.com,team@example.com
EMAIL_SUBJECT_PREFIX=Interac Intelligence
# SMTP path
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=you@example.com
SMTP_PASSWORD=<app password>
# Resend path
RESEND_API_KEY=<resend api key>
RESEND_API_URL=https://api.resend.com/emailsState and workbook links:
STATE_DIR=/data # point at a mounted volume in production
ATTACH_STATE_EXCEL_ON_BIWEEKLY=1 # Telegram-deliver workbooks after each run
STATE_EXCEL_TELEGRAM_CHAT_ID=-1001234567890
WORKBOOK_S3_BUCKET=your-bucket
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_REGION=us-east-1
S3_ENDPOINT_URL= # set for R2 or MinIO
WORKBOOK_PUBLIC_BASE_URL=https://files.example.com/my-prefix
WORKBOOK_S3_PREFIX=interac-intel/workbooksNever hardcode a key. Everything above is read from the environment.
Any container host works. The included Dockerfile builds on python:3.12-slim; Procfile runs python app.py.
- Push to GitHub and point the host at the repo.
- Set at minimum
TELEGRAM_TOKENandKIMI_API_KEY. - Mount a volume and set
STATE_DIRto it, otherwise workbooks and scan memory are lost on redeploy. - Verify the sending domain with your email provider.
- Send
/statusin Telegram, then/email.
HANDOFF.md is the operational runbook: env vars, common failure modes, and where to change what.
app.pyis a 4,888-line monolith. Fetch, filter, scoring, prompt assembly, HTML rendering, Excel logging, and every Telegram handler live in one file. It is the honest cost of shipping this solo and iterating on output quality over 121 commits. Splitting it intosources/,analysis/,render/, andbot/is the first thing to do next.- Subscription state, the last report, and rate-limit counters are in memory and reset on restart.
- Workbooks and scan memory survive process restarts but not redeploys unless
STATE_DIRis a mounted volume. - DuckDuckGo result quality depends on upstream indexing. X/Twitter coverage through DDG is thin, which is why twitterapi.io is a separate path.
manifest.jsonis a leftover Microsoft Teams artifact and is unused.
MIT. See LICENSE.