From e8b30743d33baeedc2a09292c8f4fc45298813a7 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 9 Apr 2026 08:58:52 +0700 Subject: [PATCH] refactor: remove cron, use KV metadata, extract shared crypto util - Remove cron status polling (statuspage notifies via email on webhook failure) - Store subscriber types/components as KV metadata for O(1) filtering - Extract timingSafeEqual to shared crypto-utils.js (was duplicated) - Change /migrate route from GET to POST (prevent CSRF/prefetch) - Preserve existing subscriber preferences on /start re-subscribe - Remove dead getAllSubscribers export - Update docs to reflect changes --- CLAUDE.md | 20 ++++------- README.md | 12 ++----- src/cron-status-check.js | 76 --------------------------------------- src/crypto-utils.js | 10 ++++++ src/kv-store.js | 58 +++++++++++++++--------------- src/statuspage-webhook.js | 11 +----- wrangler.jsonc | 3 -- 7 files changed, 50 insertions(+), 140 deletions(-) delete mode 100644 src/cron-status-check.js create mode 100644 src/crypto-utils.js diff --git a/CLAUDE.md b/CLAUDE.md index 3669348..6e8de80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,10 +22,9 @@ No test framework configured yet. No linter configured. ## Architecture -Cloudflare Workers with three entry points exported from `src/index.js`: +Cloudflare Workers with two entry points exported from `src/index.js`: - **`fetch`** — Hono.js HTTP handler (routes below) - **`queue`** — CF Queues consumer for fan-out message delivery -- **`scheduled`** — CF Cron Trigger (every 5 min) for status polling safety net ### Routes @@ -34,14 +33,13 @@ Cloudflare Workers with three entry points exported from `src/index.js`: | GET | `/` | inline | Health check | | POST | `/webhook/telegram` | `bot-commands.js` | grammY `webhookCallback("cloudflare-mod")` | | POST | `/webhook/status/:secret` | `statuspage-webhook.js` | Receives Statuspage webhooks (URL secret) | -| GET | `/migrate/:secret` | inline | One-time KV migration (remove after use) | +| POST | `/migrate/:secret` | inline | One-time KV migration (remove after use) | ### Data Flow -1. **Statuspage → Worker**: Webhook POST → verify URL secret (timing-safe) → parse incident/component event → filter subscribers by type + component → `sendBatch` to CF Queue -2. **Cron → Worker**: Every 5 min → fetch summary → compare with `last-status` KV → notify on changes → update stored state -3. **Queue → Telegram**: Consumer processes batches of 30 → `sendMessage` via `telegram-api.js` helper → auto-removes blocked subscribers (403/400), retries on 429 -4. **User → Bot**: Telegram webhook → grammY handles `/help`, `/start`, `/stop`, `/status`, `/subscribe`, `/history`, `/uptime` commands → reads/writes KV +1. **Statuspage → Worker**: Webhook POST → verify URL secret (timing-safe via `crypto-utils.js`) → parse incident/component event → filter subscribers by type + component → `sendBatch` to CF Queue +2. **Queue → Telegram**: Consumer processes batches of 30 → `sendMessage` via `telegram-api.js` helper → auto-removes blocked subscribers (403/400), retries on 429 +3. **User → Bot**: Telegram webhook → grammY handles `/help`, `/start`, `/stop`, `/status`, `/subscribe`, `/history`, `/uptime` commands → reads/writes KV ### KV Storage @@ -49,14 +47,11 @@ Per-subscriber keys (no read-modify-write races): - `sub:{chatId}` → `{ types: ["incident", "component"], components: [] }` - `sub:{chatId}:{threadId}` → `{ types: ["incident"], components: ["API"] }` -Special keys: -- `last-status` — JSON snapshot of component statuses for cron comparison - -`kv-store.js` handles key building/parsing with `kv.list({ prefix: "sub:" })` pagination. `threadId` can be `0` (General topic), so null checks use `!= null`. +`kv-store.js` handles key building/parsing with `kv.list({ prefix: "sub:" })` pagination. Subscriber type/component data is stored as KV metadata so `getSubscribersByType()` uses only `list()` (O(1)) instead of individual `get()` calls. `threadId` can be `0` (General topic), so null checks use `!= null`. ### Component-Specific Subscriptions -Subscribers can filter to specific components via `/subscribe component `. Empty `components` array = all components (default). Filtering applies to both webhook and cron notifications. +Subscribers can filter to specific components via `/subscribe component `. Empty `components` array = all components (default). Filtering applies to webhook notifications. ### Supergroup Topic Support @@ -72,4 +67,3 @@ Bot stores `message_thread_id` from the topic where `/start` was sent. Notificat - `claude_status` — KV namespace - `claude-status` — Queue producer/consumer (batch size 30, max retries 3) -- Cron: `*/5 * * * *` — triggers `scheduled` export every 5 minutes diff --git a/README.md b/README.md index f4f179c..8cc6ec2 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,6 @@ Hosted on [Cloudflare Workers](https://workers.cloudflare.com/) with KV for stor - **Component-specific filtering** — subscribe to specific components (e.g., API only) - **Supergroup topic support** — send `/start` in a specific topic and notifications go to that topic - **On-demand status check** — `/status` fetches live data from status.claude.com -- **Automatic status monitoring** — cron checks every 5 minutes as a safety net - **Self-healing** — automatically removes subscribers who block the bot ## Bot Commands @@ -110,18 +109,12 @@ Replace `` with the secret you set in step 4. If you have existing subscribers from an older version, run the migration endpoint once: -``` -https:///migrate/ +```bash +curl -X POST https:///migrate/ ``` This converts the old single-key format to per-subscriber KV keys. Remove the `/migrate` route from `src/index.js` after confirming success. -## Automatic Status Monitoring - -The bot checks status.claude.com every 5 minutes via Cloudflare Cron Triggers (free tier). If a component status changes between checks, subscribers are notified automatically. This acts as a safety net in case Statuspage webhooks are delayed or missed. - -Cron-detected changes are tagged with "(detected by status check)" to distinguish from webhook notifications. - ## Local Development ```bash @@ -160,7 +153,6 @@ curl -X POST http://localhost:8787/webhook/status/your-test-secret \ - **Runtime**: [Cloudflare Workers](https://workers.cloudflare.com/) - **Storage**: [Cloudflare KV](https://developers.cloudflare.com/kv/) - **Queue**: [Cloudflare Queues](https://developers.cloudflare.com/queues/) -- **Cron**: [Cloudflare Cron Triggers](https://developers.cloudflare.com/workers/configuration/cron-triggers/) - **HTTP framework**: [Hono](https://hono.dev/) - **Telegram framework**: [grammY](https://grammy.dev/) diff --git a/src/cron-status-check.js b/src/cron-status-check.js deleted file mode 100644 index 8f6db71..0000000 --- a/src/cron-status-check.js +++ /dev/null @@ -1,76 +0,0 @@ -import { fetchSummary, humanizeStatus, escapeHtml } from "./status-fetcher.js"; -import { getSubscribersByType } from "./kv-store.js"; -const LAST_STATUS_KEY = "last-status"; - -/** - * Build a map of component name -> status from summary - */ -function buildStatusMap(summary) { - const map = {}; - for (const c of summary.components) { - if (!c.group) map[c.name] = c.status; - } - return map; -} - -/** - * Format a component status change detected by cron - */ -function formatChangeMessage(name, oldStatus, newStatus) { - return ( - `Component Update: ${escapeHtml(name)}\n` + - `${humanizeStatus(oldStatus)} → ${humanizeStatus(newStatus)}\n` + - `(detected by status check)` - ); -} - -/** - * CF Scheduled handler — polls status page, notifies on changes - */ -export async function handleScheduled(env) { - const kv = env.claude_status; - const queue = env["claude-status"]; - - let summary; - try { - summary = await fetchSummary(); - } catch (err) { - console.error("Cron: failed to fetch status:", err); - return; - } - - const currentMap = buildStatusMap(summary); - const stored = await kv.get(LAST_STATUS_KEY, "json"); - const previousMap = stored?.components || {}; - - // Find changes (only if previous state exists for that component) - const changes = []; - for (const [name, status] of Object.entries(currentMap)) { - if (previousMap[name] && previousMap[name] !== status) { - changes.push({ name, oldStatus: previousMap[name], newStatus: status }); - } - } - - // Always update stored state (proves cron is running) - await kv.put(LAST_STATUS_KEY, JSON.stringify({ - components: currentMap, - timestamp: new Date().toISOString(), - })); - - if (changes.length === 0) return; - - console.log(`Cron: ${changes.length} component change(s) detected`); - - // Enqueue notifications for each change - for (const { name, oldStatus, newStatus } of changes) { - const html = formatChangeMessage(name, oldStatus, newStatus); - const subscribers = await getSubscribersByType(kv, "component", name); - const messages = subscribers.map(({ chatId, threadId }) => ({ - body: { chatId, threadId, html }, - })); - for (let i = 0; i < messages.length; i += 100) { - await queue.sendBatch(messages.slice(i, i + 100)); - } - console.log(`Cron: enqueued ${messages.length} messages for ${name} change`); - } -} diff --git a/src/crypto-utils.js b/src/crypto-utils.js new file mode 100644 index 0000000..4a284ec --- /dev/null +++ b/src/crypto-utils.js @@ -0,0 +1,10 @@ +/** + * Timing-safe string comparison using Web Crypto API + */ +export async function timingSafeEqual(a, b) { + const encoder = new TextEncoder(); + const bufA = encoder.encode(a); + const bufB = encoder.encode(b); + if (bufA.byteLength !== bufB.byteLength) return false; + return crypto.subtle.timingSafeEqual(bufA, bufB); +} diff --git a/src/kv-store.js b/src/kv-store.js index 3c83f7a..cf67f14 100644 --- a/src/kv-store.js +++ b/src/kv-store.js @@ -30,6 +30,14 @@ function parseKvKey(kvKey) { return { chatId: raw, threadId: null }; } +/** + * Build KV metadata object for subscriber filtering via list(). + * Stored as KV key metadata so getSubscribersByType() needs only list(), not get(). + */ +function buildMetadata(types, components) { + return { types, components }; +} + /** * List all subscriber KV keys with cursor pagination */ @@ -45,13 +53,18 @@ async function listAllSubscriberKeys(kv) { } /** - * Add or re-subscribe a user with default types + * Add or re-subscribe a user. Preserves existing types and components if already subscribed. */ export async function addSubscriber(kv, chatId, threadId, types = ["incident", "component"]) { const key = buildKvKey(chatId, threadId); const existing = await kv.get(key, "json"); - const value = { types, components: existing?.components || [] }; - await kv.put(key, JSON.stringify(value)); + const value = { + types: existing?.types || types, + components: existing?.components || [], + }; + await kv.put(key, JSON.stringify(value), { + metadata: buildMetadata(value.types, value.components), + }); } /** @@ -70,7 +83,9 @@ export async function updateSubscriberTypes(kv, chatId, threadId, types) { const existing = await kv.get(key, "json"); if (!existing) return false; existing.types = types; - await kv.put(key, JSON.stringify(existing)); + await kv.put(key, JSON.stringify(existing), { + metadata: buildMetadata(existing.types, existing.components), + }); return true; } @@ -82,7 +97,9 @@ export async function updateSubscriberComponents(kv, chatId, threadId, component const existing = await kv.get(key, "json"); if (!existing) return false; existing.components = components; - await kv.put(key, JSON.stringify(existing)); + await kv.put(key, JSON.stringify(existing), { + metadata: buildMetadata(existing.types, existing.components), + }); return true; } @@ -96,19 +113,18 @@ export async function getSubscriber(kv, chatId, threadId) { /** * Get subscribers filtered by event type and optional component name. - * Returns [{ chatId, threadId, ...value }, ...] + * Uses KV metadata from list() — O(1) list call, no individual get() needed. */ export async function getSubscribersByType(kv, eventType, componentName = null) { const keys = await listAllSubscriberKeys(kv); const results = []; - for (const { name } of keys) { - const value = await kv.get(name, "json"); - if (!value || !value.types.includes(eventType)) continue; + for (const { name, metadata } of keys) { + if (!metadata?.types?.includes(eventType)) continue; // Component-specific filtering - if (eventType === "component" && componentName && value.components?.length > 0) { - const match = value.components.some( + if (eventType === "component" && componentName && metadata.components?.length > 0) { + const match = metadata.components.some( (c) => c.toLowerCase() === componentName.toLowerCase() ); if (!match) continue; @@ -121,21 +137,6 @@ export async function getSubscribersByType(kv, eventType, componentName = null) return results; } -/** - * Get all subscribers with their full data - */ -export async function getAllSubscribers(kv) { - const keys = await listAllSubscriberKeys(kv); - const results = []; - for (const { name } of keys) { - const value = await kv.get(name, "json"); - if (!value) continue; - const { chatId, threadId } = parseKvKey(name); - results.push({ chatId, threadId, ...value }); - } - return results; -} - /** * One-time migration from single-key "subscribers" to per-key format. * Returns count of migrated entries. @@ -146,9 +147,10 @@ export async function migrateFromSingleKey(kv) { const entries = Object.entries(old); for (const [compositeKey, value] of entries) { - // Preserve components field if it exists, default to empty const data = { types: value.types || [], components: value.components || [] }; - await kv.put(`${KEY_PREFIX}${compositeKey}`, JSON.stringify(data)); + await kv.put(`${KEY_PREFIX}${compositeKey}`, JSON.stringify(data), { + metadata: buildMetadata(data.types, data.components), + }); } // Verify migrated count before deleting old key diff --git a/src/statuspage-webhook.js b/src/statuspage-webhook.js index 706652a..70e888f 100644 --- a/src/statuspage-webhook.js +++ b/src/statuspage-webhook.js @@ -1,15 +1,6 @@ import { getSubscribersByType } from "./kv-store.js"; import { humanizeStatus, escapeHtml } from "./status-fetcher.js"; -/** - * Timing-safe string comparison - */ -async function timingSafeEqual(a, b) { - const encoder = new TextEncoder(); - const bufA = encoder.encode(a); - const bufB = encoder.encode(b); - if (bufA.byteLength !== bufB.byteLength) return false; - return crypto.subtle.timingSafeEqual(bufA, bufB); -} +import { timingSafeEqual } from "./crypto-utils.js"; /** * Format incident event as Telegram HTML message diff --git a/wrangler.jsonc b/wrangler.jsonc index f5189a7..41b4e9b 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -23,9 +23,6 @@ "max_retries": 3 } ] - }, - "triggers": { - "crons": ["*/5 * * * *"] } // Secrets (set via `wrangler secret put`): // BOT_TOKEN - Telegram bot token