/** * @file Webhook provider registry. Each provider is described declaratively — * its display label, "family" (which determines optional HMAC/custom-header * support), the credential fields it needs, how its outbound URL is resolved, * any auth headers, and a payload formatter that turns a fired alert into that * provider's native request body. server/lib/webhooks.js consumes this registry * to build and deliver requests; routes/webhooks.js uses it for validation and * for the redacted provider metadata exposed to the UI. * * Adding a provider = one entry here (+ a formatter). No delivery/route changes. * @author Nguyễn Ngọc Trí Vĩ */ // ── Shared helpers ────────────────────────────────────────────────────────── function truncate(value, max) { const s = String(value == null ? "" : value); return s.length > max ? `${s.slice(0, max - 1)}…` : s; } function escHtml(value) { return String(value == null ? "" : value) .replace(/&/g, "&") .replace(//g, ">"); } function parseDetails(alert) { if (alert.details == null) return null; if (typeof alert.details === "object") return alert.details; try { return JSON.parse(alert.details); } catch { return alert.details; } } // [{ title, value, short }] for the chat platforms that use Slack-style // attachment fields (Slack legacy, Mattermost, Rocket.Chat). function attachmentFields(alert) { const fields = [{ title: "Type", value: alert.rule_type, short: true }]; if (alert.session_id) fields.push({ title: "Session", value: truncate(alert.session_id, 120), short: true }); if (alert.agent_id) fields.push({ title: "Agent", value: truncate(alert.agent_id, 120), short: true }); return fields; } const ACCENT_HEX = "#EF4444"; const ACCENT_INT = 0xef4444; // ── Formatters ────────────────────────────────────────────────────────────── // Slack incoming webhook — Block Kit. `text` is the required fallback string. function formatSlack(alert) { const ctx = [`Type: \`${alert.rule_type}\``]; if (alert.session_id) ctx.push(`Session: \`${truncate(alert.session_id, 64)}\``); if (alert.agent_id) ctx.push(`Agent: \`${truncate(alert.agent_id, 64)}\``); ctx.push(alert.triggered_at); return { text: truncate(`🔔 ${alert.rule_name}: ${alert.message}`, 3000), blocks: [ { type: "header", text: { type: "plain_text", text: truncate(`🔔 ${alert.rule_name}`, 150), emoji: true }, }, { type: "section", text: { type: "mrkdwn", text: truncate(alert.message, 2900) } }, { type: "context", elements: [{ type: "mrkdwn", text: truncate(ctx.join(" • "), 1900) }] }, ], }; } // Discord webhook — a single rich embed. function formatDiscord(alert) { const fields = [{ name: "Type", value: truncate(alert.rule_type, 1024), inline: true }]; if (alert.session_id) fields.push({ name: "Session", value: truncate(alert.session_id, 1024), inline: true }); if (alert.agent_id) fields.push({ name: "Agent", value: truncate(alert.agent_id, 1024), inline: true }); return { username: "Claude Code Monitor", embeds: [ { title: truncate(`🔔 ${alert.rule_name}`, 256), description: truncate(alert.message, 4000), color: ACCENT_INT, fields, footer: { text: "Claude Code Agent Monitor" }, timestamp: alert.triggered_at, }, ], }; } // Microsoft Teams — Adaptive Card delivered via a Power Automate "Workflows" // webhook. The legacy O365 Connector + MessageCard transport was retired // (connectors progressively disabled May 18–22 2026), so the target URL is a // Workflows "When a Teams webhook request is received" URL and the body is the // {type:"message", attachments:[adaptive card]} envelope that flow expects. function formatTeams(alert) { const facts = [{ title: "Type", value: alert.rule_type }]; if (alert.session_id) facts.push({ title: "Session", value: truncate(alert.session_id, 256) }); if (alert.agent_id) facts.push({ title: "Agent", value: truncate(alert.agent_id, 256) }); facts.push({ title: "Triggered", value: alert.triggered_at }); return { type: "message", attachments: [ { contentType: "application/vnd.microsoft.card.adaptive", contentUrl: null, content: { $schema: "http://adaptivecards.io/schemas/adaptive-card.json", type: "AdaptiveCard", version: "1.4", body: [ { type: "TextBlock", size: "Large", weight: "Bolder", color: "Attention", text: truncate(`🔔 ${alert.rule_name}`, 500), wrap: true, }, { type: "TextBlock", text: truncate(alert.message, 4000), wrap: true }, { type: "FactSet", facts }, ], }, }, ], }; } // Google Chat incoming webhook — simple text message with basic markdown // (*bold*, `code`). Reliable across spaces without card-schema pitfalls. function formatGoogleChat(alert) { const lines = [`🔔 *${alert.rule_name}*`, alert.message, ""]; const meta = [`\`${alert.rule_type}\``]; if (alert.session_id) meta.push(`session \`${truncate(alert.session_id, 64)}\``); if (alert.agent_id) meta.push(`agent \`${truncate(alert.agent_id, 64)}\``); lines.push(meta.join(" · ")); return { text: truncate(lines.join("\n"), 4000) }; } // Mattermost incoming webhook — Slack-compatible (legacy attachments). function formatMattermost(alert) { return { username: "Claude Code Monitor", text: `🔔 **${alert.rule_name}**`, attachments: [ { fallback: truncate(`${alert.rule_name}: ${alert.message}`, 1000), color: ACCENT_HEX, text: truncate(alert.message, 3000), fields: attachmentFields(alert), footer: "Claude Code Agent Monitor", }, ], }; } // Rocket.Chat incoming webhook — text + Slack-style attachments. function formatRocketChat(alert) { return { alias: "Claude Code Monitor", text: `🔔 *${alert.rule_name}*`, attachments: [ { title: truncate(alert.rule_name, 256), text: truncate(alert.message, 3000), color: ACCENT_HEX, fields: attachmentFields(alert), }, ], }; } // Telegram Bot API sendMessage. chat_id comes from config; the bot token is in // the resolved URL. HTML parse mode, so message text is HTML-escaped. function formatTelegram(alert, config) { const lines = [`🔔 ${escHtml(alert.rule_name)}`, escHtml(alert.message)]; const meta = [`${escHtml(alert.rule_type)}`]; if (alert.session_id) meta.push(`session ${escHtml(truncate(alert.session_id, 64))}`); lines.push("", meta.join(" · ")); return { chat_id: config.chat_id, parse_mode: "HTML", disable_web_page_preview: true, text: truncate(lines.join("\n"), 4096), }; } // PagerDuty Events API v2 (trigger). routing_key + severity from config. // dedup_key groups repeat firings of the same rule+session into one incident. function formatPagerDuty(alert, config) { return { routing_key: config.routing_key, event_action: "trigger", dedup_key: `ccam:${alert.rule_id || "test"}:${alert.session_id || ""}`, payload: { summary: truncate(`${alert.rule_name}: ${alert.message}`, 1024), source: alert.session_id || "claude-code-agent-monitor", severity: config.severity || "warning", custom_details: { rule_name: alert.rule_name, rule_type: alert.rule_type, session_id: alert.session_id || null, agent_id: alert.agent_id || null, message: alert.message, details: parseDetails(alert), triggered_at: alert.triggered_at, }, }, }; } // Opsgenie Alert API. api_key is sent as the GenieKey auth header (see // authFrom), not in the body. alias dedups; region selects the host. function formatOpsgenie(alert) { return { message: truncate(`${alert.rule_name}: ${alert.message}`, 130), alias: `ccam:${alert.rule_id || "test"}:${alert.session_id || ""}`, description: truncate(alert.message, 15000), source: "claude-code-agent-monitor", tags: ["claude-code", alert.rule_type].filter(Boolean), details: { rule_name: String(alert.rule_name), rule_type: String(alert.rule_type), session_id: alert.session_id ? String(alert.session_id) : "", agent_id: alert.agent_id ? String(alert.agent_id) : "", triggered_at: String(alert.triggered_at), }, }; } // Splunk On-Call (VictorOps) generic REST endpoint. The API + routing key live // in the user-pasted URL; severity maps to message_type. function formatSplunkOnCall(alert, config) { return { message_type: config.severity || "WARNING", entity_id: `ccam:${alert.rule_id || "test"}:${alert.session_id || ""}`, entity_display_name: truncate(alert.rule_name, 256), state_message: truncate( `${alert.message}\n\ntype: ${alert.rule_type}${alert.session_id ? `\nsession: ${alert.session_id}` : ""}`, 20000 ), monitoring_tool: "claude-code-agent-monitor", }; } // Generic / automation platforms (Zapier, Make, n8n, Pipedream) — a clean, // stable JSON envelope. Optional HMAC signing + custom headers handled by the // caller (server/lib/webhooks.js) for the whole generic family. function formatGeneric(alert) { return { event: "alert.triggered", source: "claude-code-agent-monitor", sent_at: new Date().toISOString(), alert: { id: alert.id ?? null, rule_id: alert.rule_id ?? null, rule_name: alert.rule_name, rule_type: alert.rule_type, session_id: alert.session_id ?? null, agent_id: alert.agent_id ?? null, message: alert.message, details: parseDetails(alert), triggered_at: alert.triggered_at, }, }; } // ── Registry ──────────────────────────────────────────────────────────────── // // family: // "chat" — incoming-webhook chat platforms (no extra auth, https URL) // "api" — alert/event APIs with credentials and/or derived URLs // "generic" — arbitrary-JSON endpoints; support optional HMAC + custom headers // // needsUrl — the user must supply the outbound URL // https — enforce https on a user-supplied URL (false allows http for local) // defaultUrl — fallback URL when the user supplies none // urlFrom(cfg)— derive the URL from config (user supplies no URL) // authFrom(cfg)— derive auth request headers from config // fields — provider config fields (rendered by the UI, validated server-side) const PROVIDERS = { slack: { label: "Slack", family: "chat", needsUrl: true, https: true, format: formatSlack }, discord: { label: "Discord", family: "chat", needsUrl: true, https: true, format: formatDiscord }, teams: { label: "Microsoft Teams", family: "chat", needsUrl: true, https: true, urlHint: "Power Automate Workflows URL (Teams → Workflows → 'Post to a channel when a webhook request is received')", format: formatTeams, }, google_chat: { label: "Google Chat", family: "chat", needsUrl: true, https: true, format: formatGoogleChat, }, mattermost: { label: "Mattermost", family: "chat", needsUrl: true, https: true, format: formatMattermost, }, rocketchat: { label: "Rocket.Chat", family: "chat", needsUrl: true, https: true, format: formatRocketChat, }, telegram: { label: "Telegram", family: "api", https: true, fields: [ { key: "bot_token", label: "Bot token", secret: true, required: true }, { key: "chat_id", label: "Chat ID", required: true }, ], urlFrom: (c) => (c.bot_token ? `https://api.telegram.org/bot${c.bot_token}/sendMessage` : null), format: formatTelegram, }, pagerduty: { label: "PagerDuty", family: "api", https: true, defaultUrl: "https://events.pagerduty.com/v2/enqueue", fields: [ { key: "routing_key", label: "Integration (routing) key", secret: true, required: true }, { key: "severity", label: "Severity", type: "enum", options: ["info", "warning", "error", "critical"], default: "warning", }, ], format: formatPagerDuty, }, opsgenie: { label: "Opsgenie", family: "api", https: true, fields: [ { key: "api_key", label: "API key", secret: true, required: true }, { key: "region", label: "Region", type: "enum", options: ["us", "eu"], default: "us" }, ], urlFrom: (c) => c.region === "eu" ? "https://api.eu.opsgenie.com/v2/alerts" : "https://api.opsgenie.com/v2/alerts", authFrom: (c) => (c.api_key ? { Authorization: `GenieKey ${c.api_key}` } : {}), format: formatOpsgenie, }, splunk_oncall: { label: "Splunk On-Call", family: "api", needsUrl: true, https: true, urlHint: "VictorOps REST endpoint URL (contains your API + routing key)", fields: [ { key: "severity", label: "Message type", type: "enum", options: ["CRITICAL", "WARNING", "INFO"], default: "WARNING", }, ], format: formatSplunkOnCall, // VictorOps returns HTTP 200 even when it rejects the event — the real // outcome is in the body ({ result: "success" | "failure" }). Inspect it so // a logical failure isn't silently recorded as delivered. verifyResponse: (text) => { if (!text) return { ok: true }; try { const j = JSON.parse(text); if (j && typeof j.result === "string" && j.result.toLowerCase() === "failure") { return { ok: false, error: j.message || "Splunk On-Call reported failure" }; } } catch { /* non-JSON 200 body — trust the status */ } return { ok: true }; }, }, zapier: { label: "Zapier", family: "generic", needsUrl: true, https: true, format: formatGeneric, }, make: { label: "Make", family: "generic", needsUrl: true, https: true, format: formatGeneric }, n8n: { label: "n8n", family: "generic", needsUrl: true, https: false, format: formatGeneric }, pipedream: { label: "Pipedream", family: "generic", needsUrl: true, https: true, format: formatGeneric, }, generic: { label: "Generic (custom JSON)", family: "generic", needsUrl: true, https: false, format: formatGeneric, }, }; const WEBHOOK_TYPES = Object.keys(PROVIDERS); function isGenericFamily(type) { return PROVIDERS[type]?.family === "generic"; } /** Resolve the outbound URL for a target: derived → user-supplied → default. */ function resolveUrl(target) { const p = PROVIDERS[target.type]; if (!p) return target.url || null; if (p.urlFrom) { const derived = p.urlFrom(target.config || {}); if (derived) return derived; } if (target.url) return target.url; return p.defaultUrl || null; } /** Provider-derived auth headers (e.g. Opsgenie GenieKey). */ function resolveAuthHeaders(target) { const p = PROVIDERS[target.type]; if (p?.authFrom) return p.authFrom(target.config || {}) || {}; return {}; } function formatPayload(type, alert, config = {}) { const p = PROVIDERS[type] || PROVIDERS.generic; return p.format(alert, config); } /** Whether a user-supplied URL is required for this provider type. */ function urlRequired(type) { const p = PROVIDERS[type]; if (!p) return true; if (p.urlFrom || p.defaultUrl) return false; return !!p.needsUrl; } /** Redacted, serializable provider metadata for the UI/API. */ function publicProviders() { return WEBHOOK_TYPES.map((type) => { const p = PROVIDERS[type]; return { type, label: p.label, family: p.family, url_required: urlRequired(type), has_default_url: !!p.defaultUrl, derives_url: !!p.urlFrom, allow_http: p.https === false, url_hint: p.urlHint || null, supports_secret: p.family === "generic", supports_headers: p.family === "generic", fields: (p.fields || []).map((f) => ({ key: f.key, label: f.label, secret: !!f.secret, required: !!f.required, type: f.type || "string", options: f.options || null, default: f.default ?? null, })), }; }); } module.exports = { PROVIDERS, WEBHOOK_TYPES, isGenericFamily, resolveUrl, resolveAuthHeaders, formatPayload, urlRequired, publicProviders, truncate, };