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
This commit is contained in:
2026-04-09 08:58:52 +07:00
parent f356c8e3e8
commit e8b30743d3
7 changed files with 50 additions and 140 deletions

View File

@@ -22,10 +22,9 @@ No test framework configured yet. No linter configured.
## Architecture ## 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) - **`fetch`** — Hono.js HTTP handler (routes below)
- **`queue`** — CF Queues consumer for fan-out message delivery - **`queue`** — CF Queues consumer for fan-out message delivery
- **`scheduled`** — CF Cron Trigger (every 5 min) for status polling safety net
### Routes ### Routes
@@ -34,14 +33,13 @@ Cloudflare Workers with three entry points exported from `src/index.js`:
| GET | `/` | inline | Health check | | GET | `/` | inline | Health check |
| POST | `/webhook/telegram` | `bot-commands.js` | grammY `webhookCallback("cloudflare-mod")` | | POST | `/webhook/telegram` | `bot-commands.js` | grammY `webhookCallback("cloudflare-mod")` |
| POST | `/webhook/status/:secret` | `statuspage-webhook.js` | Receives Statuspage webhooks (URL secret) | | 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 ### 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 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. **Cron → Worker**: Every 5 min → fetch summary → compare with `last-status` KV → notify on changes → update stored state 2. **Queue → Telegram**: Consumer processes batches of 30 → `sendMessage` via `telegram-api.js` helper → auto-removes blocked subscribers (403/400), retries on 429
3. **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
4. **User → Bot**: Telegram webhook → grammY handles `/help`, `/start`, `/stop`, `/status`, `/subscribe`, `/history`, `/uptime` commands → reads/writes KV
### KV Storage ### KV Storage
@@ -49,14 +47,11 @@ Per-subscriber keys (no read-modify-write races):
- `sub:{chatId}``{ types: ["incident", "component"], components: [] }` - `sub:{chatId}``{ types: ["incident", "component"], components: [] }`
- `sub:{chatId}:{threadId}``{ types: ["incident"], components: ["API"] }` - `sub:{chatId}:{threadId}``{ types: ["incident"], components: ["API"] }`
Special keys: `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`.
- `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`.
### Component-Specific Subscriptions ### Component-Specific Subscriptions
Subscribers can filter to specific components via `/subscribe component <name>`. Empty `components` array = all components (default). Filtering applies to both webhook and cron notifications. Subscribers can filter to specific components via `/subscribe component <name>`. Empty `components` array = all components (default). Filtering applies to webhook notifications.
### Supergroup Topic Support ### 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` — KV namespace
- `claude-status` — Queue producer/consumer (batch size 30, max retries 3) - `claude-status` — Queue producer/consumer (batch size 30, max retries 3)
- Cron: `*/5 * * * *` — triggers `scheduled` export every 5 minutes

View File

@@ -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) - **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 - **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 - **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 - **Self-healing** — automatically removes subscribers who block the bot
## Bot Commands ## Bot Commands
@@ -110,18 +109,12 @@ Replace `<WEBHOOK_SECRET>` with the secret you set in step 4.
If you have existing subscribers from an older version, run the migration endpoint once: If you have existing subscribers from an older version, run the migration endpoint once:
``` ```bash
https://<WORKER_URL>/migrate/<WEBHOOK_SECRET> curl -X POST https://<WORKER_URL>/migrate/<WEBHOOK_SECRET>
``` ```
This converts the old single-key format to per-subscriber KV keys. Remove the `/migrate` route from `src/index.js` after confirming success. 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 ## Local Development
```bash ```bash
@@ -160,7 +153,6 @@ curl -X POST http://localhost:8787/webhook/status/your-test-secret \
- **Runtime**: [Cloudflare Workers](https://workers.cloudflare.com/) - **Runtime**: [Cloudflare Workers](https://workers.cloudflare.com/)
- **Storage**: [Cloudflare KV](https://developers.cloudflare.com/kv/) - **Storage**: [Cloudflare KV](https://developers.cloudflare.com/kv/)
- **Queue**: [Cloudflare Queues](https://developers.cloudflare.com/queues/) - **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/) - **HTTP framework**: [Hono](https://hono.dev/)
- **Telegram framework**: [grammY](https://grammy.dev/) - **Telegram framework**: [grammY](https://grammy.dev/)

View File

@@ -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 (
`<b>Component Update: ${escapeHtml(name)}</b>\n` +
`${humanizeStatus(oldStatus)} → <b>${humanizeStatus(newStatus)}</b>\n` +
`<i>(detected by status check)</i>`
);
}
/**
* 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`);
}
}

10
src/crypto-utils.js Normal file
View File

@@ -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);
}

View File

@@ -30,6 +30,14 @@ function parseKvKey(kvKey) {
return { chatId: raw, threadId: null }; 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 * 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"]) { export async function addSubscriber(kv, chatId, threadId, types = ["incident", "component"]) {
const key = buildKvKey(chatId, threadId); const key = buildKvKey(chatId, threadId);
const existing = await kv.get(key, "json"); const existing = await kv.get(key, "json");
const value = { types, components: existing?.components || [] }; const value = {
await kv.put(key, JSON.stringify(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"); const existing = await kv.get(key, "json");
if (!existing) return false; if (!existing) return false;
existing.types = types; 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; return true;
} }
@@ -82,7 +97,9 @@ export async function updateSubscriberComponents(kv, chatId, threadId, component
const existing = await kv.get(key, "json"); const existing = await kv.get(key, "json");
if (!existing) return false; if (!existing) return false;
existing.components = components; 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; return true;
} }
@@ -96,19 +113,18 @@ export async function getSubscriber(kv, chatId, threadId) {
/** /**
* Get subscribers filtered by event type and optional component name. * 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) { export async function getSubscribersByType(kv, eventType, componentName = null) {
const keys = await listAllSubscriberKeys(kv); const keys = await listAllSubscriberKeys(kv);
const results = []; const results = [];
for (const { name } of keys) { for (const { name, metadata } of keys) {
const value = await kv.get(name, "json"); if (!metadata?.types?.includes(eventType)) continue;
if (!value || !value.types.includes(eventType)) continue;
// Component-specific filtering // Component-specific filtering
if (eventType === "component" && componentName && value.components?.length > 0) { if (eventType === "component" && componentName && metadata.components?.length > 0) {
const match = value.components.some( const match = metadata.components.some(
(c) => c.toLowerCase() === componentName.toLowerCase() (c) => c.toLowerCase() === componentName.toLowerCase()
); );
if (!match) continue; if (!match) continue;
@@ -121,21 +137,6 @@ export async function getSubscribersByType(kv, eventType, componentName = null)
return results; 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. * One-time migration from single-key "subscribers" to per-key format.
* Returns count of migrated entries. * Returns count of migrated entries.
@@ -146,9 +147,10 @@ export async function migrateFromSingleKey(kv) {
const entries = Object.entries(old); const entries = Object.entries(old);
for (const [compositeKey, value] of entries) { for (const [compositeKey, value] of entries) {
// Preserve components field if it exists, default to empty
const data = { types: value.types || [], components: value.components || [] }; 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 // Verify migrated count before deleting old key

View File

@@ -1,15 +1,6 @@
import { getSubscribersByType } from "./kv-store.js"; import { getSubscribersByType } from "./kv-store.js";
import { humanizeStatus, escapeHtml } from "./status-fetcher.js"; import { humanizeStatus, escapeHtml } from "./status-fetcher.js";
/** import { timingSafeEqual } from "./crypto-utils.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);
}
/** /**
* Format incident event as Telegram HTML message * Format incident event as Telegram HTML message

View File

@@ -23,9 +23,6 @@
"max_retries": 3 "max_retries": 3
} }
] ]
},
"triggers": {
"crons": ["*/5 * * * *"]
} }
// Secrets (set via `wrangler secret put`): // Secrets (set via `wrangler secret put`):
// BOT_TOKEN - Telegram bot token // BOT_TOKEN - Telegram bot token