mirror of
https://github.com/tiennm99/claude-status-webhook.git
synced 2026-04-17 13:21:01 +00:00
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:
1673
package-lock.json
generated
Normal file
1673
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "claude-status-webhook",
|
||||
"version": "1.0.0",
|
||||
"description": "[Claude Status](https://status.claude.com/)'s webhook for Telegram bot. Hosted on Cloudflare Workers",
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/tiennm99/claude-status-webhook.git"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://github.com/tiennm99/claude-status-webhook/issues"
|
||||
},
|
||||
"homepage": "https://github.com/tiennm99/claude-status-webhook#readme",
|
||||
"dependencies": {
|
||||
"grammy": "^1.42.0",
|
||||
"hono": "^4.12.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.81.0"
|
||||
}
|
||||
}
|
||||
114
src/bot-commands.js
Normal file
114
src/bot-commands.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Bot, webhookCallback } from "grammy";
|
||||
import {
|
||||
addSubscriber,
|
||||
removeSubscriber,
|
||||
updateSubscriberTypes,
|
||||
getSubscribers,
|
||||
buildSubscriberKey,
|
||||
} from "./kv-store.js";
|
||||
import {
|
||||
fetchAllComponents,
|
||||
fetchComponentByName,
|
||||
formatComponentLine,
|
||||
} from "./status-fetcher.js";
|
||||
|
||||
/**
|
||||
* Extract chatId and threadId from grammY context
|
||||
*/
|
||||
function getChatTarget(ctx) {
|
||||
return {
|
||||
chatId: ctx.chat.id,
|
||||
threadId: ctx.message?.message_thread_id || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming Telegram webhook via grammY
|
||||
*/
|
||||
export async function handleTelegramWebhook(c) {
|
||||
const bot = new Bot(c.env.BOT_TOKEN);
|
||||
const kv = c.env.SUBSCRIBERS;
|
||||
|
||||
bot.command("start", async (ctx) => {
|
||||
const { chatId, threadId } = getChatTarget(ctx);
|
||||
await addSubscriber(kv, chatId, threadId);
|
||||
await ctx.reply(
|
||||
"Subscribed to Claude status updates (incidents + components).\n" +
|
||||
"Use /subscribe to change preferences.\n" +
|
||||
"Use /stop to unsubscribe.",
|
||||
{ parse_mode: "HTML" }
|
||||
);
|
||||
});
|
||||
|
||||
bot.command("stop", async (ctx) => {
|
||||
const { chatId, threadId } = getChatTarget(ctx);
|
||||
await removeSubscriber(kv, chatId, threadId);
|
||||
await ctx.reply("Unsubscribed from Claude status updates. Use /start to resubscribe.", {
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
});
|
||||
|
||||
bot.command("status", async (ctx) => {
|
||||
const args = ctx.match?.trim();
|
||||
try {
|
||||
if (args) {
|
||||
const component = await fetchComponentByName(args);
|
||||
if (!component) {
|
||||
await ctx.reply(`Component "<code>${args}</code>" not found.`, { parse_mode: "HTML" });
|
||||
return;
|
||||
}
|
||||
await ctx.reply(formatComponentLine(component), { parse_mode: "HTML" });
|
||||
} else {
|
||||
const components = await fetchAllComponents();
|
||||
const lines = components.map(formatComponentLine);
|
||||
await ctx.reply(`<b>Claude Status</b>\n\n${lines.join("\n")}`, { parse_mode: "HTML" });
|
||||
}
|
||||
} catch {
|
||||
await ctx.reply("Unable to fetch status. Please try again later.");
|
||||
}
|
||||
});
|
||||
|
||||
bot.command("subscribe", async (ctx) => {
|
||||
const { chatId, threadId } = getChatTarget(ctx);
|
||||
const arg = ctx.match?.trim().toLowerCase();
|
||||
|
||||
const validTypes = {
|
||||
incident: ["incident"],
|
||||
component: ["component"],
|
||||
all: ["incident", "component"],
|
||||
};
|
||||
|
||||
if (!arg || !validTypes[arg]) {
|
||||
const key = buildSubscriberKey(chatId, threadId);
|
||||
const subs = await getSubscribers(kv);
|
||||
const current = subs[key]?.types?.join(", ") || "none (use /start first)";
|
||||
await ctx.reply(
|
||||
"<b>Usage:</b> /subscribe <type>\n\n" +
|
||||
"Types: <code>incident</code>, <code>component</code>, <code>all</code>\n" +
|
||||
`\nCurrent: <code>${current}</code>`,
|
||||
{ parse_mode: "HTML" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await updateSubscriberTypes(kv, chatId, threadId, validTypes[arg]);
|
||||
await ctx.reply(`Subscription updated: <code>${validTypes[arg].join(", ")}</code>`, {
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
});
|
||||
|
||||
bot.on("message", async (ctx) => {
|
||||
await ctx.reply(
|
||||
"<b>Claude Status Bot</b>\n\n" +
|
||||
"/start — Subscribe to notifications\n" +
|
||||
"/stop — Unsubscribe\n" +
|
||||
"/status — Check current status\n" +
|
||||
"/status <component> — Check specific component\n" +
|
||||
"/subscribe <type> — Set notification preference",
|
||||
{ parse_mode: "HTML" }
|
||||
);
|
||||
});
|
||||
|
||||
const handler = webhookCallback(bot, "cloudflare-mod");
|
||||
return handler(c.req.raw);
|
||||
}
|
||||
15
src/index.js
Normal file
15
src/index.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Hono } from "hono";
|
||||
import { handleTelegramWebhook } from "./bot-commands.js";
|
||||
import { handleStatuspageWebhook } from "./statuspage-webhook.js";
|
||||
import { handleQueue } from "./queue-consumer.js";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/", (c) => c.text("Claude Status Bot is running"));
|
||||
app.post("/webhook/telegram", (c) => handleTelegramWebhook(c));
|
||||
app.post("/webhook/status/:secret", (c) => handleStatuspageWebhook(c));
|
||||
|
||||
export default {
|
||||
fetch: app.fetch,
|
||||
queue: handleQueue,
|
||||
};
|
||||
84
src/kv-store.js
Normal file
84
src/kv-store.js
Normal file
@@ -0,0 +1,84 @@
|
||||
const KV_KEY = "claude-status:subscribers";
|
||||
|
||||
/**
|
||||
* Build composite key: "chatId" or "chatId:threadId" for supergroup topics
|
||||
*/
|
||||
export function buildSubscriberKey(chatId, threadId) {
|
||||
return threadId != null ? `${chatId}:${threadId}` : `${chatId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse composite key back into { chatId, threadId }
|
||||
*/
|
||||
export function parseSubscriberKey(key) {
|
||||
const parts = key.split(":");
|
||||
if (parts.length >= 2 && parts[0].startsWith("-")) {
|
||||
// Supergroup IDs start with "-100", so key is "-100xxx:threadId"
|
||||
// Find the last ":" — everything before is chatId, after is threadId
|
||||
const lastColon = key.lastIndexOf(":");
|
||||
return {
|
||||
chatId: key.slice(0, lastColon),
|
||||
threadId: parseInt(key.slice(lastColon + 1), 10),
|
||||
};
|
||||
}
|
||||
return { chatId: key, threadId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all subscribers from KV
|
||||
*/
|
||||
export async function getSubscribers(kv) {
|
||||
const data = await kv.get(KV_KEY, "json");
|
||||
return data || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Write all subscribers to KV
|
||||
*/
|
||||
async function setSubscribers(kv, data) {
|
||||
await kv.put(KV_KEY, JSON.stringify(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add subscriber with default types
|
||||
*/
|
||||
export async function addSubscriber(kv, chatId, threadId, types = ["incident", "component"]) {
|
||||
const subs = await getSubscribers(kv);
|
||||
const key = buildSubscriberKey(chatId, threadId);
|
||||
subs[key] = { types };
|
||||
await setSubscribers(kv, subs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove subscriber
|
||||
*/
|
||||
export async function removeSubscriber(kv, chatId, threadId) {
|
||||
const subs = await getSubscribers(kv);
|
||||
const key = buildSubscriberKey(chatId, threadId);
|
||||
delete subs[key];
|
||||
await setSubscribers(kv, subs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update subscriber's notification type preferences
|
||||
*/
|
||||
export async function updateSubscriberTypes(kv, chatId, threadId, types) {
|
||||
const subs = await getSubscribers(kv);
|
||||
const key = buildSubscriberKey(chatId, threadId);
|
||||
if (!subs[key]) {
|
||||
subs[key] = { types };
|
||||
} else {
|
||||
subs[key].types = types;
|
||||
}
|
||||
await setSubscribers(kv, subs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscribers filtered by event type, returns [{ chatId, threadId }, ...]
|
||||
*/
|
||||
export async function getSubscribersByType(kv, eventType) {
|
||||
const subs = await getSubscribers(kv);
|
||||
return Object.entries(subs)
|
||||
.filter(([, val]) => val.types.includes(eventType))
|
||||
.map(([key]) => parseSubscriberKey(key));
|
||||
}
|
||||
53
src/queue-consumer.js
Normal file
53
src/queue-consumer.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { removeSubscriber } from "./kv-store.js";
|
||||
|
||||
const TELEGRAM_API = "https://api.telegram.org/bot";
|
||||
|
||||
/**
|
||||
* Process a batch of queued messages, sending each to Telegram.
|
||||
* Handles rate limits (429 → retry), blocked bots (403/400 → remove subscriber).
|
||||
*/
|
||||
export async function handleQueue(batch, env) {
|
||||
for (const msg of batch.messages) {
|
||||
const { chatId, threadId, html } = msg.body;
|
||||
|
||||
// Defensive check for malformed messages
|
||||
if (!chatId || !html) {
|
||||
msg.ack();
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
chat_id: chatId,
|
||||
text: html,
|
||||
parse_mode: "HTML",
|
||||
disable_web_page_preview: true,
|
||||
};
|
||||
// Send to specific supergroup topic if threadId present
|
||||
if (threadId) payload.message_thread_id = threadId;
|
||||
|
||||
const res = await fetch(`${TELEGRAM_API}${env.BOT_TOKEN}/sendMessage`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
msg.ack();
|
||||
} else if (res.status === 403 || res.status === 400) {
|
||||
// Bot blocked or chat not found — auto-remove subscriber
|
||||
await removeSubscriber(env.SUBSCRIBERS, chatId, threadId);
|
||||
msg.ack();
|
||||
} else if (res.status === 429) {
|
||||
// Rate limited — let queue retry later
|
||||
msg.retry();
|
||||
} else {
|
||||
// Unknown error — ack to avoid infinite retry
|
||||
msg.ack();
|
||||
}
|
||||
} catch {
|
||||
// Network error — retry
|
||||
msg.retry();
|
||||
}
|
||||
}
|
||||
}
|
||||
53
src/status-fetcher.js
Normal file
53
src/status-fetcher.js
Normal file
@@ -0,0 +1,53 @@
|
||||
const STATUS_API = "https://status.claude.com/api/v2/summary.json";
|
||||
|
||||
/**
|
||||
* Fetch all components from status.claude.com (excludes group-level entries)
|
||||
*/
|
||||
export async function fetchAllComponents() {
|
||||
const res = await fetch(STATUS_API);
|
||||
if (!res.ok) throw new Error(`Status API returned ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.components.filter((c) => !c.group);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuzzy match a component by name (case-insensitive includes)
|
||||
*/
|
||||
export async function fetchComponentByName(name) {
|
||||
const components = await fetchAllComponents();
|
||||
return components.find((c) =>
|
||||
c.name.toLowerCase().includes(name.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable status label
|
||||
*/
|
||||
export function humanizeStatus(status) {
|
||||
const map = {
|
||||
operational: "Operational",
|
||||
degraded_performance: "Degraded Performance",
|
||||
partial_outage: "Partial Outage",
|
||||
major_outage: "Major Outage",
|
||||
under_maintenance: "Under Maintenance",
|
||||
investigating: "Investigating",
|
||||
identified: "Identified",
|
||||
monitoring: "Monitoring",
|
||||
resolved: "Resolved",
|
||||
};
|
||||
return map[status] || status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special chars for Telegram's HTML parse mode
|
||||
*/
|
||||
export function escapeHtml(s) {
|
||||
return s?.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single component as HTML line
|
||||
*/
|
||||
export function formatComponentLine(component) {
|
||||
return `<b>${escapeHtml(component.name)}</b>: <code>${humanizeStatus(component.status)}</code>`;
|
||||
}
|
||||
83
src/statuspage-webhook.js
Normal file
83
src/statuspage-webhook.js
Normal 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);
|
||||
}
|
||||
19
wrangler.jsonc
Normal file
19
wrangler.jsonc
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "claude-status-webhook",
|
||||
"main": "src/index.js",
|
||||
"compatibility_date": "2024-12-01",
|
||||
"kv_namespaces": [
|
||||
{ "binding": "SUBSCRIBERS", "id": "<KV_NAMESPACE_ID>" }
|
||||
],
|
||||
"queues": {
|
||||
"producers": [
|
||||
{ "binding": "STATUS_QUEUE", "queue": "status-notifications" }
|
||||
],
|
||||
"consumers": [
|
||||
{ "queue": "status-notifications", "max_batch_size": 30, "max_retries": 3 }
|
||||
]
|
||||
}
|
||||
// Secrets (set via `wrangler secret put`):
|
||||
// BOT_TOKEN - Telegram bot token
|
||||
// WEBHOOK_SECRET - Statuspage webhook URL secret
|
||||
}
|
||||
Reference in New Issue
Block a user