mirror of
https://github.com/tiennm99/claude-status-webhook.git
synced 2026-04-17 13:21:01 +00:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 <name>` | 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 <count>` | Show up to 10 recent incidents |
|
||||
| `/uptime` | Component health overview with last status change time |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@@ -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 "<code>${args}</code>" 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(`<b>Claude Status</b>\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(
|
||||
"<b>Claude Status Bot</b>\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" }
|
||||
);
|
||||
});
|
||||
|
||||
118
src/bot-info-commands.js
Normal file
118
src/bot-info-commands.js
Normal file
@@ -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(
|
||||
`<b>Claude Status Bot — Help</b>\n\n` +
|
||||
`<b>/start</b>\n` +
|
||||
`Subscribe to Claude status notifications.\n` +
|
||||
`Default: incidents + component updates.\n\n` +
|
||||
`<b>/stop</b>\n` +
|
||||
`Unsubscribe from all notifications.\n\n` +
|
||||
`<b>/status</b> [component]\n` +
|
||||
`Show current status of all components.\n` +
|
||||
`Add a component name for a specific check.\n` +
|
||||
`Example: <code>/status api</code>\n\n` +
|
||||
`<b>/subscribe</b> <type>\n` +
|
||||
`Set what notifications you receive.\n` +
|
||||
`Options: <code>incident</code>, <code>component</code>, <code>all</code>\n` +
|
||||
`Example: <code>/subscribe incident</code>\n\n` +
|
||||
`<b>/history</b> [count]\n` +
|
||||
`Show recent incidents. Default: 5, max: 10.\n` +
|
||||
`Example: <code>/history 3</code>\n\n` +
|
||||
`<b>/uptime</b>\n` +
|
||||
`Show current component health overview.\n\n` +
|
||||
`<a href="${STATUS_URL}">status.claude.com</a>`,
|
||||
{ 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 "<code>${args}</code>" 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(
|
||||
`<b>${overall}</b>\n\n` +
|
||||
`${lines.join("\n")}\n\n` +
|
||||
`<i>Updated: ${updated} UTC</i>\n` +
|
||||
`<a href="${STATUS_URL}">View full status page</a>`,
|
||||
{ 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(
|
||||
`<b>Recent Incidents</b>\n\n` +
|
||||
`${lines.join("\n\n")}\n\n` +
|
||||
`<a href="${STATUS_URL}/history">View full history</a>`,
|
||||
{ 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} <b>${c.name}</b>\n Status: <code>${humanizeStatus(c.status)}</code>\n Last change: ${upSince} UTC`;
|
||||
});
|
||||
await ctx.reply(
|
||||
`<b>${overall}</b>\n\n` +
|
||||
`${lines.join("\n\n")}\n\n` +
|
||||
`<i>Uptime % not available via public API.</i>\n` +
|
||||
`<a href="${STATUS_URL}">View uptime on status page</a>`,
|
||||
{ parse_mode: "HTML", disable_web_page_preview: true }
|
||||
);
|
||||
} catch {
|
||||
await ctx.reply("Unable to fetch uptime data. Please try again later.");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 `<b>${escapeHtml(component.name)}</b>: <code>${humanizeStatus(component.status)}</code>`;
|
||||
return `${statusIndicator(component.status)} <b>${escapeHtml(component.name)}</b> — <code>${humanizeStatus(component.status)}</code>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 += `<b>[${impact}]</b> ${escapeHtml(incident.name)}\n`;
|
||||
line += ` ${date} — <code>${status}</code>`;
|
||||
if (incident.shortlink) {
|
||||
line += ` — <a href="${incident.shortlink}">Details</a>`;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user