mirror of
https://github.com/tiennm99/claude-status-webhook.git
synced 2026-04-17 15:20:37 +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:
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));
|
||||
}
|
||||
Reference in New Issue
Block a user