Files
Claude-Code-Monitor/server/lib/webhook-providers.js
T
nntrivi2001 57dc91585d feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard.

Lanes: a durable unit of parallel agent work, one per working directory,
tracked across session restarts. Managed lanes are git worktrees the
dashboard provisions and can reset or remove behind a three-check destroy
guard and a counted preflight; adopted lanes are directories you already
own and are never destroyable.

Pipelines: a lane moves through pipeline stages. A stage the agent declares
with evidence renders green; a stage inferred from the tool-event stream
renders dashed amber and never counts as done. Detection is forward-only
within a 30-minute window, and never writes the declared stage.

Workspace: one page at /run with a lane grid, the selected lane's pipeline,
and a full Claude console behind a disclosure.
2026-07-30 14:39:03 +07:00

502 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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ĩ <vinnt@smartgift.vn>
*/
// ── 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
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 1822 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 = [`🔔 <b>${escHtml(alert.rule_name)}</b>`, escHtml(alert.message)];
const meta = [`<code>${escHtml(alert.rule_type)}</code>`];
if (alert.session_id)
meta.push(`session <code>${escHtml(truncate(alert.session_id, 64))}</code>`);
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,
};