191 lines
5.6 KiB
TypeScript
191 lines
5.6 KiB
TypeScript
import {
|
|
type EventPriority,
|
|
type Notifier,
|
|
type NotifyAction,
|
|
type NotifyContext,
|
|
type OrchestratorEvent,
|
|
type PluginModule,
|
|
} from "@composio/ao-core";
|
|
import { isRetryableHttpStatus, normalizeRetryConfig, validateUrl } from "@composio/ao-core/utils";
|
|
|
|
export const manifest = {
|
|
name: "openclaw",
|
|
slot: "notifier" as const,
|
|
description: "Notifier plugin: OpenClaw webhook notifications",
|
|
version: "0.1.0",
|
|
};
|
|
|
|
type WakeMode = "now" | "next-heartbeat";
|
|
|
|
interface OpenClawWebhookPayload {
|
|
message: string;
|
|
name?: string;
|
|
sessionKey?: string;
|
|
wakeMode?: WakeMode;
|
|
deliver?: boolean;
|
|
}
|
|
|
|
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++) {
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (response.ok) return;
|
|
|
|
const body = await response.text();
|
|
lastError = new Error(`OpenClaw webhook failed (${response.status}): ${body}`);
|
|
|
|
if (!isRetryableHttpStatus(response.status)) {
|
|
throw lastError;
|
|
}
|
|
|
|
if (attempt < retries) {
|
|
console.warn(
|
|
`[notifier-openclaw] Retry ${attempt + 1}/${retries} for session=${context.sessionId} after HTTP ${response.status}`,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
if (err === lastError) throw err;
|
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
|
|
if (attempt < retries) {
|
|
console.warn(
|
|
`[notifier-openclaw] Retry ${attempt + 1}/${retries} for session=${context.sessionId} after network error: ${lastError.message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
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}`;
|
|
}
|
|
|
|
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 =
|
|
(typeof config?.token === "string" ? config.token : undefined) ??
|
|
process.env.OPENCLAW_HOOKS_TOKEN;
|
|
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 { retries, retryDelayMs } = normalizeRetryConfig(config);
|
|
|
|
validateUrl(url, "notifier-openclaw");
|
|
|
|
if (!token) {
|
|
console.warn(
|
|
"[notifier-openclaw] No token configured (token or OPENCLAW_HOOKS_TOKEN). Sending without Authorization header.",
|
|
);
|
|
}
|
|
|
|
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";
|
|
|
|
await postWithRetry(url, payload, headers, retries, retryDelayMs, { sessionId });
|
|
}
|
|
|
|
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>;
|