feat: implement Telegram bot for Claude status webhooks

Cloudflare Workers bot that forwards status.claude.com (Atlassian
Statuspage) incident and component updates to subscribed Telegram
users via CF Queues fan-out.

- Hono.js routing with grammY webhook handler
- Bot commands: /start, /stop, /status, /subscribe
- Supergroup topic support (message_thread_id)
- KV store with claude-status: prefix and composite keys
- Queue consumer with batch send, 403 auto-removal, 429 retry
- Timing-safe webhook secret validation
- HTML escaping for Telegram messages
This commit is contained in:
2026-04-08 22:59:37 +07:00
parent 902b46720d
commit 01320abacd
9 changed files with 2123 additions and 0 deletions

83
src/statuspage-webhook.js Normal file
View File

@@ -0,0 +1,83 @@
import { getSubscribersByType } from "./kv-store.js";
import { humanizeStatus, escapeHtml } from "./status-fetcher.js";
/**
* Format incident event as Telegram HTML message
*/
function formatIncidentMessage(incident) {
const impact = incident.impact?.toUpperCase() || "UNKNOWN";
const latestUpdate = incident.incident_updates?.[0];
let html = `<b>[${impact}] ${escapeHtml(incident.name)}</b>\n`;
html += `Status: <code>${humanizeStatus(incident.status)}</code>\n`;
if (latestUpdate?.body) {
html += `\n${escapeHtml(latestUpdate.body)}\n`;
}
if (incident.shortlink) {
html += `\n<a href="${incident.shortlink}">View details</a>`;
}
return html;
}
/**
* Format component status change as Telegram HTML message
*/
function formatComponentMessage(component, update) {
let html = `<b>Component Update: ${escapeHtml(component.name)}</b>\n`;
if (update) {
html += `${humanizeStatus(update.old_status)} → <b>${humanizeStatus(update.new_status)}</b>`;
} else {
html += `Status: <b>${humanizeStatus(component.status)}</b>`;
}
return html;
}
/**
* Handle incoming Statuspage webhook
*/
export async function handleStatuspageWebhook(c) {
// Validate secret (timing-safe comparison)
const secret = c.req.param("secret");
const encoder = new TextEncoder();
const a = encoder.encode(secret);
const b = encoder.encode(c.env.WEBHOOK_SECRET);
if (a.byteLength !== b.byteLength || !crypto.subtle.timingSafeEqual(a, b)) {
return c.text("Unauthorized", 401);
}
// Parse body
let body;
try {
body = await c.req.json();
} catch {
return c.text("Bad Request", 400);
}
const eventType = body?.meta?.event_type;
if (!eventType) return c.text("Bad Request", 400);
// Determine category and format message
let category, html;
if (eventType.startsWith("incident.")) {
category = "incident";
html = formatIncidentMessage(body.incident);
} else if (eventType.startsWith("component.")) {
category = "component";
html = formatComponentMessage(body.component, body.component_update);
} else {
return c.text("Unknown event type", 400);
}
// Get filtered subscribers
const subscribers = await getSubscribersByType(c.env.SUBSCRIBERS, category);
// Enqueue messages for fan-out via CF Queues (batch for performance)
const messages = subscribers.map(({ chatId, threadId }) => ({
body: { chatId, threadId, html },
}));
for (let i = 0; i < messages.length; i += 100) {
await c.env.STATUS_QUEUE.sendBatch(messages.slice(i, i + 100));
}
return c.text("OK", 200);
}