371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import {
|
|
type EventPriority,
|
|
type Notifier,
|
|
type NotifyAction,
|
|
type NotifyContext,
|
|
type OrchestratorEvent,
|
|
type PluginModule,
|
|
getObservabilityBaseDir,
|
|
recordActivityEvent,
|
|
} from "@aoagents/ao-core";
|
|
import { isRetryableHttpStatus, normalizeRetryConfig, validateUrl } from "@aoagents/ao-core/utils";
|
|
|
|
/**
|
|
* Read the hooks token from ~/.openclaw/openclaw.json as a fallback for
|
|
* daemon contexts where the shell profile (and OPENCLAW_HOOKS_TOKEN) isn't
|
|
* sourced. This file is written by `ao setup openclaw` and lives outside
|
|
* the project directory so it's never committed to version control.
|
|
*/
|
|
function readTokenFromOpenClawConfig(): string | undefined {
|
|
try {
|
|
const configPath = join(homedir(), ".openclaw", "openclaw.json");
|
|
if (!existsSync(configPath)) return undefined;
|
|
const raw = readFileSync(configPath, "utf-8");
|
|
const config = JSON.parse(raw) as Record<string, unknown>;
|
|
const token = (config.hooks as Record<string, unknown> | undefined)?.token;
|
|
return typeof token === "string" && token ? token : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export const manifest = {
|
|
name: "openclaw",
|
|
slot: "notifier" as const,
|
|
description: "Notifier plugin: OpenClaw webhook notifications",
|
|
version: "0.1.0",
|
|
};
|
|
|
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
const UNREACHABLE_NETWORK_ERROR_CODES = [
|
|
"ECONNREFUSED",
|
|
"ETIMEDOUT",
|
|
"ENOTFOUND",
|
|
"ENETUNREACH",
|
|
] as const;
|
|
type UnreachableNetworkErrorCode = (typeof UNREACHABLE_NETWORK_ERROR_CODES)[number];
|
|
|
|
type WakeMode = "now" | "next-heartbeat";
|
|
|
|
interface OpenClawWebhookPayload {
|
|
message: string;
|
|
name?: string;
|
|
sessionKey?: string;
|
|
wakeMode?: WakeMode;
|
|
deliver?: boolean;
|
|
}
|
|
|
|
interface OpenClawHealthSummary {
|
|
lastSuccessAt: string | null;
|
|
lastFailureAt: string | null;
|
|
lastFailureError: string | null;
|
|
totalSent: number;
|
|
totalFailed: number;
|
|
}
|
|
|
|
const DEFAULT_HEALTH_SUMMARY: OpenClawHealthSummary = {
|
|
lastSuccessAt: null,
|
|
lastFailureAt: null,
|
|
lastFailureError: null,
|
|
totalSent: 0,
|
|
totalFailed: 0,
|
|
};
|
|
|
|
function readHealthSummary(path: string): OpenClawHealthSummary {
|
|
try {
|
|
if (!existsSync(path)) return { ...DEFAULT_HEALTH_SUMMARY };
|
|
const raw = readFileSync(path, "utf-8");
|
|
return {
|
|
...DEFAULT_HEALTH_SUMMARY,
|
|
...(JSON.parse(raw) as Partial<OpenClawHealthSummary>),
|
|
};
|
|
} catch {
|
|
return { ...DEFAULT_HEALTH_SUMMARY };
|
|
}
|
|
}
|
|
|
|
function writeHealthSummary(path: string, summary: OpenClawHealthSummary): void {
|
|
try {
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
const tempPath = `${path}.tmp.${process.pid}`;
|
|
writeFileSync(tempPath, JSON.stringify(summary, null, 2) + "\n");
|
|
renameSync(tempPath, path);
|
|
} catch {
|
|
// Health telemetry is best-effort and must never block notifications.
|
|
}
|
|
}
|
|
|
|
function getHealthSummaryPath(config?: Record<string, unknown>): string | null {
|
|
const explicitPath =
|
|
typeof config?.healthSummaryPath === "string" ? config.healthSummaryPath : undefined;
|
|
if (explicitPath) return explicitPath;
|
|
|
|
const configPath = typeof config?.configPath === "string" ? config.configPath : undefined;
|
|
if (!configPath) return null;
|
|
return join(getObservabilityBaseDir(configPath), "openclaw-health.json");
|
|
}
|
|
|
|
function recordHealthSuccess(path: string | null): void {
|
|
if (!path) return;
|
|
const summary = readHealthSummary(path);
|
|
summary.lastSuccessAt = new Date().toISOString();
|
|
summary.totalSent += 1;
|
|
writeHealthSummary(path, summary);
|
|
}
|
|
|
|
function recordHealthFailure(path: string | null, error: unknown): void {
|
|
if (!path) return;
|
|
const summary = readHealthSummary(path);
|
|
summary.lastFailureAt = new Date().toISOString();
|
|
summary.lastFailureError = error instanceof Error ? error.message : String(error);
|
|
summary.totalFailed += 1;
|
|
writeHealthSummary(path, summary);
|
|
}
|
|
|
|
function getUnreachableNetworkErrorCode(error: Error): UnreachableNetworkErrorCode | undefined {
|
|
return UNREACHABLE_NETWORK_ERROR_CODES.find((code) => error.message.includes(code));
|
|
}
|
|
|
|
async function postWithRetry(
|
|
url: string,
|
|
payload: OpenClawWebhookPayload,
|
|
headers: Record<string, string>,
|
|
retries: number,
|
|
retryDelayMs: number,
|
|
context: { sessionId: string },
|
|
): Promise<void> {
|
|
let lastError: Error | undefined;
|
|
|
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
|
|
let shouldRethrowResponseError = false;
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(payload),
|
|
signal: controller.signal,
|
|
});
|
|
|
|
if (response.ok) return;
|
|
|
|
const body = await response.text();
|
|
|
|
if (response.status === 401 || response.status === 403) {
|
|
// User-actionable: distinct from generic 5xx — token expired or wrong.
|
|
recordActivityEvent({
|
|
sessionId: context.sessionId,
|
|
source: "notifier",
|
|
kind: "notifier.auth_failed",
|
|
level: "error",
|
|
summary: `OpenClaw rejected auth token (HTTP ${response.status})`,
|
|
data: {
|
|
plugin: "notifier-openclaw",
|
|
status: response.status,
|
|
url,
|
|
fixHint: "ao setup openclaw",
|
|
},
|
|
});
|
|
lastError = new Error(
|
|
`OpenClaw rejected the auth token (HTTP ${response.status}).\n` +
|
|
` Check that hooks.token in your OpenClaw config matches the token configured for AO.\n` +
|
|
` Reconfigure: ao setup openclaw`,
|
|
);
|
|
shouldRethrowResponseError = true;
|
|
throw lastError;
|
|
}
|
|
|
|
lastError = new Error(`OpenClaw webhook failed (${response.status}): ${body}`);
|
|
|
|
if (!isRetryableHttpStatus(response.status)) {
|
|
shouldRethrowResponseError = true;
|
|
throw lastError;
|
|
}
|
|
|
|
if (attempt < retries) {
|
|
console.warn(
|
|
`[notifier-openclaw] Retry ${attempt + 1}/${retries} for session=${context.sessionId} after HTTP ${response.status}`,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
if (shouldRethrowResponseError && err === lastError) throw err;
|
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
|
|
const unreachableCode = getUnreachableNetworkErrorCode(lastError);
|
|
if (unreachableCode && (unreachableCode === "ECONNREFUSED" || attempt >= retries)) {
|
|
recordActivityEvent({
|
|
sessionId: context.sessionId,
|
|
source: "notifier",
|
|
kind: "notifier.unreachable",
|
|
level: "warn",
|
|
summary: `OpenClaw gateway unreachable at ${url}`,
|
|
data: {
|
|
plugin: "notifier-openclaw",
|
|
url,
|
|
errorMessage: lastError.message,
|
|
fixHint: "openclaw status",
|
|
},
|
|
});
|
|
throw new Error(
|
|
`Can't reach OpenClaw gateway at ${url}.\n` +
|
|
` Is OpenClaw running? Check: openclaw status\n` +
|
|
` Wrong URL? Run: ao setup openclaw`,
|
|
{ cause: err },
|
|
);
|
|
}
|
|
|
|
if (attempt < retries) {
|
|
console.warn(
|
|
`[notifier-openclaw] Retry ${attempt + 1}/${retries} for session=${context.sessionId} after network error: ${lastError.message}`,
|
|
);
|
|
}
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
|
|
if (attempt < retries) {
|
|
const delay = retryDelayMs * 2 ** attempt;
|
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
}
|
|
}
|
|
|
|
throw lastError;
|
|
}
|
|
|
|
function sanitizeSessionId(id: string): string {
|
|
return id.replace(/[^a-zA-Z0-9:_-]/g, "-");
|
|
}
|
|
|
|
function eventHeadline(event: OrchestratorEvent): string {
|
|
const priorityTag: Record<EventPriority, string> = {
|
|
urgent: "URGENT",
|
|
action: "ACTION",
|
|
warning: "WARNING",
|
|
info: "INFO",
|
|
};
|
|
return `[AO ${priorityTag[event.priority]}] ${event.sessionId} ${event.type}`;
|
|
}
|
|
|
|
function stringifyData(data: Record<string, unknown>): string {
|
|
const entries = Object.entries(data);
|
|
if (entries.length === 0) return "";
|
|
return `Context: ${JSON.stringify(data)}`;
|
|
}
|
|
|
|
function formatEscalationMessage(event: OrchestratorEvent): string {
|
|
const parts = [eventHeadline(event), event.message, stringifyData(event.data)].filter(Boolean);
|
|
return parts.join("\n");
|
|
}
|
|
|
|
function formatActionsLine(actions: NotifyAction[]): string {
|
|
if (actions.length === 0) return "";
|
|
const labels = actions.map((a) => a.label).join(", ");
|
|
return `Actions available: ${labels}`;
|
|
}
|
|
|
|
/**
|
|
* Resolve a token value that may be a `${ENV_VAR}` placeholder (as written
|
|
* into agent-orchestrator.yaml by `ao setup openclaw`) or a literal string.
|
|
* Returns undefined for empty/unresolvable values so callers can chain `??`.
|
|
*/
|
|
function resolveEnvVarToken(raw: unknown): string | undefined {
|
|
if (typeof raw !== "string" || !raw) return undefined;
|
|
const match = raw.match(/^\$\{([^}]+)\}$/);
|
|
if (match) return process.env[match[1]] || undefined;
|
|
return raw;
|
|
}
|
|
|
|
export function create(config?: Record<string, unknown>): Notifier {
|
|
const url =
|
|
(typeof config?.url === "string" ? config.url : undefined) ??
|
|
"http://127.0.0.1:18789/hooks/agent";
|
|
const token =
|
|
resolveEnvVarToken(config?.token) ??
|
|
process.env.OPENCLAW_HOOKS_TOKEN ??
|
|
readTokenFromOpenClawConfig();
|
|
const senderName = typeof config?.name === "string" ? config.name : "AO";
|
|
const sessionKeyPrefix =
|
|
typeof config?.sessionKeyPrefix === "string" ? config.sessionKeyPrefix : "hook:ao:";
|
|
const wakeMode: WakeMode = config?.wakeMode === "next-heartbeat" ? "next-heartbeat" : "now";
|
|
const deliver = typeof config?.deliver === "boolean" ? config.deliver : true;
|
|
const healthSummaryPath = getHealthSummaryPath(config);
|
|
|
|
const { retries, retryDelayMs } = normalizeRetryConfig(config);
|
|
|
|
validateUrl(url, "notifier-openclaw");
|
|
|
|
if (!token) {
|
|
console.warn(
|
|
"[notifier-openclaw] No token configured.\n" +
|
|
" Set OPENCLAW_HOOKS_TOKEN env var, or add token to your notifier config.\n" +
|
|
" Run: ao setup openclaw",
|
|
);
|
|
}
|
|
|
|
async function sendPayload(payload: OpenClawWebhookPayload): Promise<void> {
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
};
|
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
|
|
const sessionId = payload.sessionKey?.slice(sessionKeyPrefix.length) ?? "default";
|
|
try {
|
|
await postWithRetry(url, payload, headers, retries, retryDelayMs, { sessionId });
|
|
recordHealthSuccess(healthSummaryPath);
|
|
} catch (err) {
|
|
recordHealthFailure(healthSummaryPath, err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
return {
|
|
name: "openclaw",
|
|
|
|
async notify(event: OrchestratorEvent): Promise<void> {
|
|
const sessionKey = `${sessionKeyPrefix}${sanitizeSessionId(event.sessionId)}`;
|
|
await sendPayload({
|
|
message: formatEscalationMessage(event),
|
|
name: senderName,
|
|
sessionKey,
|
|
wakeMode,
|
|
deliver,
|
|
});
|
|
},
|
|
|
|
async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise<void> {
|
|
const sessionKey = `${sessionKeyPrefix}${sanitizeSessionId(event.sessionId)}`;
|
|
const actionsLine = formatActionsLine(actions);
|
|
const message = [formatEscalationMessage(event), actionsLine].filter(Boolean).join("\n");
|
|
|
|
await sendPayload({
|
|
message,
|
|
name: senderName,
|
|
sessionKey,
|
|
wakeMode,
|
|
deliver,
|
|
});
|
|
},
|
|
|
|
async post(message: string, context?: NotifyContext): Promise<string | null> {
|
|
const sessionId = context?.sessionId ? sanitizeSessionId(context.sessionId) : "default";
|
|
const sessionKey = `${sessionKeyPrefix}${sessionId}`;
|
|
|
|
await sendPayload({
|
|
message,
|
|
name: senderName,
|
|
sessionKey,
|
|
wakeMode,
|
|
deliver,
|
|
});
|
|
|
|
return null;
|
|
},
|
|
};
|
|
}
|
|
|
|
export default { manifest, create } satisfies PluginModule<Notifier>;
|