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

1673
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View 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
View 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 &lt;type&gt;\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 &lt;component&gt; — Check specific component\n" +
"/subscribe &lt;type&gt; — Set notification preference",
{ parse_mode: "HTML" }
);
});
const handler = webhookCallback(bot, "cloudflare-mod");
return handler(c.req.raw);
}

15
src/index.js Normal file
View 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
View 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
View 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
View 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") ?? "";
}
/**
* 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
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);
}

19
wrangler.jsonc Normal file
View 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
}