mirror of
https://github.com/tiennm99/claude-status-webhook.git
synced 2026-04-17 15:20:37 +00:00
refactor: per-subscriber KV keys, HMAC verification, cron trigger
Major refactor addressing scalability, security, and reliability:
- KV schema: single-key → per-subscriber keys (sub:{chatId}:{threadId})
eliminates read-modify-write race conditions
- Component-specific subscriptions: /subscribe component <name>
- HMAC-SHA256 webhook verification with URL secret fallback
- Cron trigger (every 5 min) polls status.claude.com as safety net
- Shared telegram-api.js module (DRY fix)
- Error logging in all catch blocks
- Migration endpoint for existing subscribers
- Setup moved to standalone script (scripts/setup-bot.js)
- Removed setup HTTP route to reduce attack surface
This commit is contained in:
178
src/kv-store.js
178
src/kv-store.js
@@ -1,84 +1,164 @@
|
||||
const KV_KEY = "subscribers";
|
||||
const KEY_PREFIX = "sub:";
|
||||
|
||||
/**
|
||||
* Build composite key: "chatId" or "chatId:threadId" for supergroup topics
|
||||
* Build KV key: "sub:{chatId}" or "sub:{chatId}:{threadId}"
|
||||
*/
|
||||
export function buildSubscriberKey(chatId, threadId) {
|
||||
return threadId != null ? `${chatId}:${threadId}` : `${chatId}`;
|
||||
function buildKvKey(chatId, threadId) {
|
||||
const suffix = threadId != null ? `${chatId}:${threadId}` : `${chatId}`;
|
||||
return `${KEY_PREFIX}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse composite key back into { chatId, threadId }
|
||||
* Parse KV key back into { chatId, threadId }
|
||||
* Key format: "sub:{chatId}" or "sub:{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(":");
|
||||
function parseKvKey(kvKey) {
|
||||
const raw = kvKey.slice(KEY_PREFIX.length);
|
||||
const lastColon = raw.lastIndexOf(":");
|
||||
// No colon or only negative sign prefix — no threadId
|
||||
if (lastColon <= 0) {
|
||||
return { chatId: raw, threadId: null };
|
||||
}
|
||||
// Check if the part after last colon is a valid threadId (numeric)
|
||||
const possibleThread = raw.slice(lastColon + 1);
|
||||
if (/^\d+$/.test(possibleThread)) {
|
||||
return {
|
||||
chatId: key.slice(0, lastColon),
|
||||
threadId: parseInt(key.slice(lastColon + 1), 10),
|
||||
chatId: raw.slice(0, lastColon),
|
||||
threadId: parseInt(possibleThread, 10),
|
||||
};
|
||||
}
|
||||
return { chatId: key, threadId: null };
|
||||
return { chatId: raw, threadId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all subscribers from KV
|
||||
* List all subscriber KV keys with cursor pagination
|
||||
*/
|
||||
export async function getSubscribers(kv) {
|
||||
const data = await kv.get(KV_KEY, "json");
|
||||
return data || {};
|
||||
async function listAllSubscriberKeys(kv) {
|
||||
const keys = [];
|
||||
let cursor = undefined;
|
||||
do {
|
||||
const result = await kv.list({ prefix: KEY_PREFIX, cursor });
|
||||
keys.push(...result.keys);
|
||||
cursor = result.list_complete ? undefined : result.cursor;
|
||||
} while (cursor);
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write all subscribers to KV
|
||||
*/
|
||||
async function setSubscribers(kv, data) {
|
||||
await kv.put(KV_KEY, JSON.stringify(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add subscriber with default types
|
||||
* Add or re-subscribe a user 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);
|
||||
const key = buildKvKey(chatId, threadId);
|
||||
const existing = await kv.get(key, "json");
|
||||
const value = { types, components: existing?.components || [] };
|
||||
await kv.put(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove subscriber
|
||||
* Remove subscriber — atomic single delete
|
||||
*/
|
||||
export async function removeSubscriber(kv, chatId, threadId) {
|
||||
const subs = await getSubscribers(kv);
|
||||
const key = buildSubscriberKey(chatId, threadId);
|
||||
delete subs[key];
|
||||
await setSubscribers(kv, subs);
|
||||
const key = buildKvKey(chatId, threadId);
|
||||
await kv.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
const key = buildKvKey(chatId, threadId);
|
||||
const existing = await kv.get(key, "json");
|
||||
if (!existing) return false;
|
||||
existing.types = types;
|
||||
await kv.put(key, JSON.stringify(existing));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscribers filtered by event type, returns [{ chatId, threadId }, ...]
|
||||
* Update subscriber's component filter list
|
||||
*/
|
||||
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));
|
||||
export async function updateSubscriberComponents(kv, chatId, threadId, components) {
|
||||
const key = buildKvKey(chatId, threadId);
|
||||
const existing = await kv.get(key, "json");
|
||||
if (!existing) return false;
|
||||
existing.components = components;
|
||||
await kv.put(key, JSON.stringify(existing));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single subscriber's data, or null if not subscribed
|
||||
*/
|
||||
export async function getSubscriber(kv, chatId, threadId) {
|
||||
const key = buildKvKey(chatId, threadId);
|
||||
return kv.get(key, "json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscribers filtered by event type and optional component name.
|
||||
* Returns [{ chatId, threadId, ...value }, ...]
|
||||
*/
|
||||
export async function getSubscribersByType(kv, eventType, componentName = null) {
|
||||
const keys = await listAllSubscriberKeys(kv);
|
||||
const results = [];
|
||||
|
||||
for (const { name } of keys) {
|
||||
const value = await kv.get(name, "json");
|
||||
if (!value || !value.types.includes(eventType)) continue;
|
||||
|
||||
// Component-specific filtering
|
||||
if (eventType === "component" && componentName && value.components?.length > 0) {
|
||||
const match = value.components.some(
|
||||
(c) => c.toLowerCase() === componentName.toLowerCase()
|
||||
);
|
||||
if (!match) continue;
|
||||
}
|
||||
|
||||
const { chatId, threadId } = parseKvKey(name);
|
||||
results.push({ chatId, threadId });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all subscribers with their full data
|
||||
*/
|
||||
export async function getAllSubscribers(kv) {
|
||||
const keys = await listAllSubscriberKeys(kv);
|
||||
const results = [];
|
||||
for (const { name } of keys) {
|
||||
const value = await kv.get(name, "json");
|
||||
if (!value) continue;
|
||||
const { chatId, threadId } = parseKvKey(name);
|
||||
results.push({ chatId, threadId, ...value });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time migration from single-key "subscribers" to per-key format.
|
||||
* Returns count of migrated entries.
|
||||
*/
|
||||
export async function migrateFromSingleKey(kv) {
|
||||
const old = await kv.get("subscribers", "json");
|
||||
if (!old) return 0;
|
||||
|
||||
const entries = Object.entries(old);
|
||||
for (const [compositeKey, value] of entries) {
|
||||
// Preserve components field if it exists, default to empty
|
||||
const data = { types: value.types || [], components: value.components || [] };
|
||||
await kv.put(`${KEY_PREFIX}${compositeKey}`, JSON.stringify(data));
|
||||
}
|
||||
|
||||
// Verify migrated count before deleting old key
|
||||
const migrated = await listAllSubscriberKeys(kv);
|
||||
if (migrated.length >= entries.length) {
|
||||
await kv.delete("subscribers");
|
||||
console.log(`Migration complete: ${entries.length} subscribers migrated`);
|
||||
} else {
|
||||
console.error(`Migration verification failed: expected ${entries.length}, got ${migrated.length}`);
|
||||
}
|
||||
|
||||
return entries.length;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user