mirror of
https://github.com/tiennm99/claude-status-webhook.git
synced 2026-04-17 15:20:37 +00:00
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:
20
CLAUDE.md
20
CLAUDE.md
@@ -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
|
|
||||||
|
|||||||
12
README.md
12
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)
|
- **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/)
|
||||||
|
|
||||||
|
|||||||
@@ -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
10
src/crypto-utils.js
Normal 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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user