From 30ffaae6126df0b291e7d105ed965cd0f3f562fe Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 8 Apr 2026 23:41:53 +0700 Subject: [PATCH] feat: add /help, /history, /uptime commands and enhance /status - /help: detailed guide with examples for all commands - /status: overall indicator, emoji markers, updated time, status page link - /history [count]: recent incidents with impact, dates, links (max 10) - /uptime: component health with last change time - Split info commands into bot-info-commands.js for modularity - Register all 7 commands in bot-setup.js --- CLAUDE.md | 2 +- README.md | 6 +- src/bot-commands.js | 37 +++--------- src/bot-info-commands.js | 118 +++++++++++++++++++++++++++++++++++++++ src/bot-setup.js | 5 +- src/status-fetcher.js | 76 +++++++++++++++++++++++-- 6 files changed, 209 insertions(+), 35 deletions(-) create mode 100644 src/bot-info-commands.js diff --git a/CLAUDE.md b/CLAUDE.md index 83a0ee0..5e800f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ Cloudflare Workers with two entry points exported from `src/index.js`: 1. **Statuspage → Worker**: Webhook POST → validate secret (timing-safe) → parse incident/component event → filter subscribers by preference → `sendBatch` to CF Queue 2. **Queue → Telegram**: Consumer processes batches of 30 → `sendMessage` via raw fetch → auto-removes blocked subscribers (403/400), retries on 429 -3. **User → Bot**: Telegram webhook → grammY handles `/start`, `/stop`, `/status`, `/subscribe` commands → reads/writes KV +3. **User → Bot**: Telegram webhook → grammY handles `/help`, `/start`, `/stop`, `/status`, `/subscribe`, `/history`, `/uptime` commands → reads/writes KV ### KV Storage diff --git a/README.md b/README.md index d72b36d..33c2956 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,17 @@ Hosted on [Cloudflare Workers](https://workers.cloudflare.com/) with KV for stor | Command | Description | |---------|-------------| +| `/help` | Detailed command guide with examples | | `/start` | Subscribe to notifications (default: incidents + components) | | `/stop` | Unsubscribe from notifications | -| `/status` | Show current status of all components | +| `/status` | Show current status with overall indicator and all components | | `/status ` | Show status of a specific component (fuzzy match) | | `/subscribe incident` | Receive incident notifications only | | `/subscribe component` | Receive component update notifications only | | `/subscribe all` | Receive both (default) | +| `/history` | Show 5 most recent incidents with impact and links | +| `/history ` | Show up to 10 recent incidents | +| `/uptime` | Component health overview with last status change time | ## Prerequisites diff --git a/src/bot-commands.js b/src/bot-commands.js index a36d85a..8623ecf 100644 --- a/src/bot-commands.js +++ b/src/bot-commands.js @@ -6,11 +6,7 @@ import { getSubscribers, buildSubscriberKey, } from "./kv-store.js"; -import { - fetchAllComponents, - fetchComponentByName, - formatComponentLine, -} from "./status-fetcher.js"; +import { registerInfoCommands } from "./bot-info-commands.js"; /** * Extract chatId and threadId from grammY context @@ -48,26 +44,6 @@ export async function handleTelegramWebhook(c) { }); }); - bot.command("status", async (ctx) => { - const args = ctx.match?.trim(); - try { - if (args) { - const component = await fetchComponentByName(args); - if (!component) { - await ctx.reply(`Component "${args}" not found.`, { parse_mode: "HTML" }); - return; - } - await ctx.reply(formatComponentLine(component), { parse_mode: "HTML" }); - } else { - const components = await fetchAllComponents(); - const lines = components.map(formatComponentLine); - await ctx.reply(`Claude Status\n\n${lines.join("\n")}`, { parse_mode: "HTML" }); - } - } catch { - await ctx.reply("Unable to fetch status. Please try again later."); - } - }); - bot.command("subscribe", async (ctx) => { const { chatId, threadId } = getChatTarget(ctx); const arg = ctx.match?.trim().toLowerCase(); @@ -97,14 +73,19 @@ export async function handleTelegramWebhook(c) { }); }); + // Info commands: /help, /status, /history, /uptime + registerInfoCommands(bot); + bot.on("message", async (ctx) => { await ctx.reply( "Claude Status Bot\n\n" + + "/help — Detailed command guide\n" + "/start — Subscribe to notifications\n" + "/stop — Unsubscribe\n" + - "/status — Check current status\n" + - "/status <component> — Check specific component\n" + - "/subscribe <type> — Set notification preference", + "/status — Current system status\n" + + "/subscribe — Notification preferences\n" + + "/history — Recent incidents\n" + + "/uptime — Component health overview", { parse_mode: "HTML" } ); }); diff --git a/src/bot-info-commands.js b/src/bot-info-commands.js new file mode 100644 index 0000000..bcf88de --- /dev/null +++ b/src/bot-info-commands.js @@ -0,0 +1,118 @@ +import { + fetchComponentByName, + fetchSummary, + fetchIncidents, + formatComponentLine, + formatOverallStatus, + formatIncidentLine, + statusIndicator, + humanizeStatus, + STATUS_URL, +} from "./status-fetcher.js"; + +/** + * Register info commands: /help, /status, /history, /uptime + */ +export function registerInfoCommands(bot) { + bot.command("help", async (ctx) => { + await ctx.reply( + `Claude Status Bot — Help\n\n` + + `/start\n` + + `Subscribe to Claude status notifications.\n` + + `Default: incidents + component updates.\n\n` + + `/stop\n` + + `Unsubscribe from all notifications.\n\n` + + `/status [component]\n` + + `Show current status of all components.\n` + + `Add a component name for a specific check.\n` + + `Example: /status api\n\n` + + `/subscribe <type>\n` + + `Set what notifications you receive.\n` + + `Options: incident, component, all\n` + + `Example: /subscribe incident\n\n` + + `/history [count]\n` + + `Show recent incidents. Default: 5, max: 10.\n` + + `Example: /history 3\n\n` + + `/uptime\n` + + `Show current component health overview.\n\n` + + `status.claude.com`, + { parse_mode: "HTML", disable_web_page_preview: true } + ); + }); + + bot.command("status", async (ctx) => { + const args = ctx.match?.trim(); + try { + if (args) { + const component = await fetchComponentByName(args); + if (!component) { + await ctx.reply(`Component "${args}" not found.`, { parse_mode: "HTML" }); + return; + } + await ctx.reply(formatComponentLine(component), { parse_mode: "HTML" }); + } else { + const summary = await fetchSummary(); + const components = summary.components.filter((c) => !c.group); + const overall = formatOverallStatus(summary.status.indicator); + const lines = components.map(formatComponentLine); + const updated = new Date(summary.page.updated_at).toLocaleString("en-US", { + dateStyle: "medium", timeStyle: "short", timeZone: "UTC", + }); + await ctx.reply( + `${overall}\n\n` + + `${lines.join("\n")}\n\n` + + `Updated: ${updated} UTC\n` + + `View full status page`, + { parse_mode: "HTML", disable_web_page_preview: true } + ); + } + } catch { + await ctx.reply("Unable to fetch status. Please try again later."); + } + }); + + bot.command("history", async (ctx) => { + const arg = ctx.match?.trim(); + const count = Math.min(Math.max(parseInt(arg, 10) || 5, 1), 10); + try { + const incidents = await fetchIncidents(count); + if (incidents.length === 0) { + await ctx.reply("No recent incidents found.", { parse_mode: "HTML" }); + return; + } + const lines = incidents.map(formatIncidentLine); + await ctx.reply( + `Recent Incidents\n\n` + + `${lines.join("\n\n")}\n\n` + + `View full history`, + { parse_mode: "HTML", disable_web_page_preview: true } + ); + } catch { + await ctx.reply("Unable to fetch incident history. Please try again later."); + } + }); + + bot.command("uptime", async (ctx) => { + try { + const summary = await fetchSummary(); + const components = summary.components.filter((c) => !c.group); + const overall = formatOverallStatus(summary.status.indicator); + const lines = components.map((c) => { + const indicator = statusIndicator(c.status); + const upSince = new Date(c.updated_at).toLocaleString("en-US", { + dateStyle: "medium", timeStyle: "short", timeZone: "UTC", + }); + return `${indicator} ${c.name}\n Status: ${humanizeStatus(c.status)}\n Last change: ${upSince} UTC`; + }); + await ctx.reply( + `${overall}\n\n` + + `${lines.join("\n\n")}\n\n` + + `Uptime % not available via public API.\n` + + `View uptime on status page`, + { parse_mode: "HTML", disable_web_page_preview: true } + ); + } catch { + await ctx.reply("Unable to fetch uptime data. Please try again later."); + } + }); +} diff --git a/src/bot-setup.js b/src/bot-setup.js index fcacb2e..61a61ce 100644 --- a/src/bot-setup.js +++ b/src/bot-setup.js @@ -1,10 +1,13 @@ const TELEGRAM_API = "https://api.telegram.org/bot"; const BOT_COMMANDS = [ + { command: "help", description: "Detailed command guide" }, { command: "start", description: "Subscribe to status notifications" }, { command: "stop", description: "Unsubscribe from notifications" }, - { command: "status", description: "Check current Claude status" }, + { command: "status", description: "Current system status" }, { command: "subscribe", description: "Set notification preferences" }, + { command: "history", description: "Recent incidents" }, + { command: "uptime", description: "Component health overview" }, ]; /** diff --git a/src/status-fetcher.js b/src/status-fetcher.js index f57dada..9f53f38 100644 --- a/src/status-fetcher.js +++ b/src/status-fetcher.js @@ -1,15 +1,37 @@ -const STATUS_API = "https://status.claude.com/api/v2/summary.json"; +const STATUS_URL = "https://status.claude.com"; +const STATUS_API = `${STATUS_URL}/api/v2`; + +export { STATUS_URL }; /** * Fetch all components from status.claude.com (excludes group-level entries) */ export async function fetchAllComponents() { - const res = await fetch(STATUS_API); + const res = await fetch(`${STATUS_API}/summary.json`); if (!res.ok) throw new Error(`Status API returned ${res.status}`); const data = await res.json(); return data.components.filter((c) => !c.group); } +/** + * Fetch summary including overall status indicator + */ +export async function fetchSummary() { + const res = await fetch(`${STATUS_API}/summary.json`); + if (!res.ok) throw new Error(`Status API returned ${res.status}`); + return res.json(); +} + +/** + * Fetch recent incidents (most recent first, up to limit) + */ +export async function fetchIncidents(limit = 5) { + const res = await fetch(`${STATUS_API}/incidents.json`); + if (!res.ok) throw new Error(`Status API returned ${res.status}`); + const data = await res.json(); + return data.incidents.slice(0, limit); +} + /** * Fuzzy match a component by name (case-insensitive includes) */ @@ -46,8 +68,54 @@ export function escapeHtml(s) { } /** - * Format a single component as HTML line + * Status indicator dot for visual formatting + */ +export function statusIndicator(status) { + const indicators = { + operational: "\u2705", // green check + degraded_performance: "\u26A0\uFE0F", // warning + partial_outage: "\uD83D\uDFE0", // orange circle + major_outage: "\uD83D\uDD34", // red circle + under_maintenance: "\uD83D\uDD27", // wrench + }; + return indicators[status] || "\u2753"; // question mark fallback +} + +/** + * Format a single component as HTML line with indicator */ export function formatComponentLine(component) { - return `${escapeHtml(component.name)}: ${humanizeStatus(component.status)}`; + return `${statusIndicator(component.status)} ${escapeHtml(component.name)}${humanizeStatus(component.status)}`; +} + +/** + * Overall status indicator text + */ +export function formatOverallStatus(indicator) { + const map = { + none: "\u2705 All Systems Operational", + minor: "\u26A0\uFE0F Minor System Issues", + major: "\uD83D\uDFE0 Major System Issues", + critical: "\uD83D\uDD34 Critical System Outage", + maintenance: "\uD83D\uDD27 Maintenance In Progress", + }; + return map[indicator] || indicator; +} + +/** + * Format a single incident for /history display + */ +export function formatIncidentLine(incident) { + const impact = incident.impact?.toUpperCase() || "UNKNOWN"; + const date = new Date(incident.created_at).toLocaleDateString("en-US", { + month: "short", day: "numeric", year: "numeric", + }); + const status = humanizeStatus(incident.status); + let line = `${statusIndicator(incident.status === "resolved" ? "operational" : "major_outage")} `; + line += `[${impact}] ${escapeHtml(incident.name)}\n`; + line += ` ${date} — ${status}`; + if (incident.shortlink) { + line += ` — Details`; + } + return line; }