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

@@ -3,9 +3,10 @@ import {
addSubscriber,
removeSubscriber,
updateSubscriberTypes,
getSubscribers,
buildSubscriberKey,
updateSubscriberComponents,
getSubscriber,
} from "./kv-store.js";
import { fetchComponentByName, escapeHtml } from "./status-fetcher.js";
import { registerInfoCommands } from "./bot-info-commands.js";
/**
@@ -46,7 +47,50 @@ export async function handleTelegramWebhook(c) {
bot.command("subscribe", async (ctx) => {
const { chatId, threadId } = getChatTarget(ctx);
const arg = ctx.match?.trim().toLowerCase();
const args = ctx.match?.trim().toLowerCase().split(/\s+/) || [];
const arg = args[0];
// Handle "/subscribe component <name>" or "/subscribe component all"
if (arg === "component" && args.length > 1) {
const componentArg = args.slice(1).join(" ");
const sub = await getSubscriber(kv, chatId, threadId);
if (!sub) {
await ctx.reply("Not subscribed yet. Use /start first.", { parse_mode: "HTML" });
return;
}
if (componentArg === "all") {
await updateSubscriberComponents(kv, chatId, threadId, []);
await ctx.reply("Component filter cleared — receiving all component updates.", {
parse_mode: "HTML",
});
return;
}
// Validate component name against live API
const component = await fetchComponentByName(componentArg);
if (!component) {
await ctx.reply(`Component "<code>${escapeHtml(componentArg)}</code>" not found.`, {
parse_mode: "HTML",
});
return;
}
// Add to component filter (deduplicate)
const components = sub.components || [];
if (!components.some((c) => c.toLowerCase() === component.name.toLowerCase())) {
components.push(component.name);
}
await updateSubscriberComponents(kv, chatId, threadId, components);
// Ensure "component" is in types
if (!sub.types.includes("component")) {
sub.types.push("component");
await updateSubscriberTypes(kv, chatId, threadId, sub.types);
}
await ctx.reply(
`Subscribed to component: <code>${escapeHtml(component.name)}</code>\n` +
`Active filters: <code>${components.join(", ")}</code>`,
{ parse_mode: "HTML" }
);
return;
}
const validTypes = {
incident: ["incident"],
@@ -55,19 +99,26 @@ export async function handleTelegramWebhook(c) {
};
if (!arg || !validTypes[arg]) {
const key = buildSubscriberKey(chatId, threadId);
const subs = await getSubscribers(kv);
const current = subs[key]?.types?.join(", ") || "none (use /start first)";
const sub = await getSubscriber(kv, chatId, threadId);
const current = sub?.types?.join(", ") || "none (use /start first)";
const compFilter = sub?.components?.length ? sub.components.join(", ") : "all";
await ctx.reply(
"<b>Usage:</b> /subscribe &lt;type&gt;\n\n" +
"<b>Usage:</b> /subscribe &lt;type&gt; [component]\n\n" +
"Types: <code>incident</code>, <code>component</code>, <code>all</code>\n" +
`\nCurrent: <code>${current}</code>`,
"Component filter: <code>/subscribe component api</code>\n" +
"Clear filter: <code>/subscribe component all</code>\n" +
`\nCurrent types: <code>${current}</code>\n` +
`Components: <code>${compFilter}</code>`,
{ parse_mode: "HTML" }
);
return;
}
await updateSubscriberTypes(kv, chatId, threadId, validTypes[arg]);
const updated = await updateSubscriberTypes(kv, chatId, threadId, validTypes[arg]);
if (!updated) {
await ctx.reply("Not subscribed yet. Use /start first.", { parse_mode: "HTML" });
return;
}
await ctx.reply(`Subscription updated: <code>${validTypes[arg].join(", ")}</code>`, {
parse_mode: "HTML",
});