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:
2026-04-09 00:43:07 +07:00
parent 30ffaae612
commit b728ae7d38
12 changed files with 443 additions and 117 deletions

View File

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