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:
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user