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

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));
}