Two runtimes, one latency boundary
Generation runs for tens of seconds — a recency-filtered news
search, then one Messages API call to compose; live commands must
answer Telegram in milliseconds.
Splitting them means a slow generation run can never make
/briefing feel slow, and a webhook hiccup can never
affect the scheduled send.
Cloudflare Worker
Webhook + cron · always on · milliseconds
-
Validates
X-Telegram-Bot-Api-Secret-Tokenon every request - Serves
/briefingfrom KV — no generation - Mutates access & subscriptions in the Durable Object
-
Cron Trigger
5 9 * * *→ dispatches the daily run
GitHub Actions
Generation + fan-out · on demand · minutes
-
fetch-news.mjssearches, thengenerate-briefing.mjscomposes the briefing -
send-briefing.mjsdelivers to every subscriber -
broadcast.mjsfans out on the runner, paced + retried -
sync-kv.mjsbridges the result back into KV
What each command does, and who may call it
Access is enforced by chat_id checks in the Worker —
never by message content. Owner-gated commands pass
isOwnerOrAdmin(); managing admins stays keyed to the
owner alone.
| Command | Access | Effect |
|---|---|---|
| /start | anyone |
Request access — adds sender to pending; enforces
the 50-user cap; the reply points at /privacy and /forgetme
|
| /briefing | allowlisted | Serves the cached copy from KV, sub-second, no generation |
| /newbriefing | allowlisted | Dispatches a fresh generation run; honors the cooldown & daily cap |
| /ask | allowlisted | Answers a question from past briefings, cited and dated; read-only over the wiki, no web access |
| /subscribe · /unsubscribe | allowlisted | Opt in / out of the daily send |
| /privacy · /mydata · /forgetme | allowlisted | Data-subject rights over everything stored about you |
| /admin · /pending · /listusers | owner / admin | Read stats, review requests, list the population |
| /adduser · /removeuser | owner / admin | Add or remove someone from the allowlist directly |
| /broadcast <msg> | owner / admin | Validated by the Worker, fanned out on the Actions runner |
| /pause · /resume | owner / admin | Freeze or lift the bot; while paused every paid path is withheld and the daily send is skipped |
| /addadmin · /removeadmin | owner only | Delegate / revoke admin — target must already be allowlisted |
-
An admin can't escalate. Admin management is keyed to
ownerChatIdalone — a delegated admin can't grant themselves more privilege or demote a peer. -
The owner is protected. Can't be removed, can't unsubscribe,
can't erase their own data — each checks
ownerChatIddirectly. -
Removal is complete.
/removeuser(or a user's own/forgetme) also revokes any admin status they held. -
A pause spends nothing. While
/pauseis on, every paid path is withheld for everyone, admins included —/newbriefing,/ask, and/briefing's generate-fallback short-circuit, and the daily send is skipped./briefingstill serves the last saved edition, and the data-subject-rights commands stay reachable.
Hard limits, enforced in code
Three limits guard generation, and the strictest one wins: a shared cooldown (shorter for the owner), a per-user daily cap that bounds hogging, and a global daily cap that bounds cost. A refused request still gets the cached edition — never a bare error.
MAX_USERS — checked atomically in the DO so two
concurrent approvals can't both slip past.
MAX_PENDING — bounds the access-request queue, capped
independently of the user allowlist.
DAILY_DISPATCH_CAP — bounds one user hogging
/newbriefing; scales with the allowlist, so it
doesn't cap cost alone.
GLOBAL_DAILY_DISPATCH_CAP — the real cost ceiling,
since every dispatch is a paid Actions + Claude run.
/ask capASK_DAILY_CAP, on its own counter with a 30 s
cooldown and a 40/day shared ceiling — a grep over the wiki is not
a paid search loop, so questions never spend the briefing
allowance.
Layered triggers, plus a heartbeat outside GitHub
Three triggers race each morning; an idempotency marker
(last_briefing_at) makes it safe for more than one to
fire — only the first to actually run does the work. But two of the
three ride GitHub's scheduler, so a Cloudflare-side heartbeat is the
only detection that survives a GitHub-wide bad morning.
Cloudflare Worker Cron Trigger
Fires scheduled() →
repository_dispatch to
daily-briefing.yml (PR #43); the minute offset
dodges GitHub's top-of-hour congestion. Best-effort too — it and
the backup missed together on 2026-07-16 (issue #61).
GitHub Actions schedule
Fires the same workflow independently. Best-effort by GitHub's own docs — observed 1–4h late or skipped entirely (issue #17), so it's a backstop, not the plan.
Stale-check fallback
If last_briefing_at still isn't today,
daily-briefing-watchdog.yml re-dispatches the run
and alerts the owner — but it too runs on GitHub's scheduler.
Cloudflare-side heartbeat
briefingHeartbeat alerts the owner if KV's
last_delivered_date isn't today. Detection only, no
dispatch — but the only layer outside GitHub's scheduler, so it
catches an account-wide Actions failure the others would miss.
Least privilege, per credential
Every secret carries the narrowest scope that still works; the allowlist holds real third-party personal data, so data-subject rights are first-class, not an afterthought.
Contents: write
Workers KV Storage: Edit — nothing more
wrangler deploy targets prod;
--env staging is required to touch staging.