Files
paperclip/packages/adapters/openclaw/src/server/execute-webhook.ts
Dotta 514dc43923 feat(openclaw): support /hooks/agent endpoint and multi-endpoint detection
Add OpenClawEndpointKind type to distinguish between /hooks/wake,
/hooks/agent, open_responses, and generic endpoints. Build appropriate
payloads per endpoint kind with optional sessionKey inclusion.
Refactor webhook execution to use endpoint-aware payload construction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:50:15 -06:00

464 lines
16 KiB
TypeScript

import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
import {
appendWakeText,
appendWakeTextToOpenResponsesInput,
buildExecutionState,
buildWakeCompatibilityPayload,
deriveHookAgentUrlFromResponses,
isTextRequiredResponse,
isWakeCompatibilityRetryableResponse,
readAndLogResponseText,
redactForLog,
resolveEndpointKind,
sendJsonRequest,
stringifyForLog,
toStringRecord,
type OpenClawEndpointKind,
type OpenClawExecutionState,
} from "./execute-common.js";
import { parseOpenClawResponse } from "./parse.js";
function nonEmpty(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function asBooleanFlag(value: unknown, fallback = false): boolean {
if (typeof value === "boolean") return value;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (normalized === "true" || normalized === "1") return true;
if (normalized === "false" || normalized === "0") return false;
}
return fallback;
}
function normalizeWakeMode(value: unknown): "now" | "next-heartbeat" | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
if (normalized === "now" || normalized === "next-heartbeat") return normalized;
return null;
}
function parseOptionalPositiveInteger(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
const normalized = Math.max(1, Math.floor(value));
return Number.isFinite(normalized) ? normalized : null;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number.parseInt(value.trim(), 10);
if (Number.isFinite(parsed)) {
const normalized = Math.max(1, Math.floor(parsed));
return Number.isFinite(normalized) ? normalized : null;
}
}
return null;
}
function buildOpenResponsesWebhookBody(input: {
state: OpenClawExecutionState;
configModel: unknown;
}): Record<string, unknown> {
const { state, configModel } = input;
const templateText = nonEmpty(state.payloadTemplate.text);
const payloadText = templateText ? appendWakeText(templateText, state.wakeText) : state.wakeText;
const openResponsesInput = Object.prototype.hasOwnProperty.call(state.payloadTemplate, "input")
? appendWakeTextToOpenResponsesInput(state.payloadTemplate.input, state.wakeText)
: payloadText;
return {
...state.payloadTemplate,
stream: false,
model:
nonEmpty(state.payloadTemplate.model) ??
nonEmpty(configModel) ??
"openclaw",
input: openResponsesInput,
metadata: {
...toStringRecord(state.payloadTemplate.metadata),
...state.paperclipEnv,
paperclip_session_key: state.sessionKey,
paperclip_stream_transport: "webhook",
},
};
}
function buildHookWakeBody(state: OpenClawExecutionState): Record<string, unknown> {
const templateText = nonEmpty(state.payloadTemplate.text) ?? nonEmpty(state.payloadTemplate.message);
const payloadText = templateText ? appendWakeText(templateText, state.wakeText) : state.wakeText;
const wakeMode = normalizeWakeMode(state.payloadTemplate.mode ?? state.payloadTemplate.wakeMode) ?? "now";
return {
text: payloadText,
mode: wakeMode,
};
}
function buildHookAgentBody(input: {
state: OpenClawExecutionState;
includeSessionKey: boolean;
}): Record<string, unknown> {
const { state, includeSessionKey } = input;
const templateMessage = nonEmpty(state.payloadTemplate.message) ?? nonEmpty(state.payloadTemplate.text);
const message = templateMessage ? appendWakeText(templateMessage, state.wakeText) : state.wakeText;
const payload: Record<string, unknown> = {
message,
};
const name = nonEmpty(state.payloadTemplate.name);
if (name) payload.name = name;
const agentId = nonEmpty(state.payloadTemplate.agentId);
if (agentId) payload.agentId = agentId;
const wakeMode = normalizeWakeMode(state.payloadTemplate.wakeMode ?? state.payloadTemplate.mode);
if (wakeMode) payload.wakeMode = wakeMode;
const deliver = state.payloadTemplate.deliver;
if (typeof deliver === "boolean") payload.deliver = deliver;
const channel = nonEmpty(state.payloadTemplate.channel);
if (channel) payload.channel = channel;
const to = nonEmpty(state.payloadTemplate.to);
if (to) payload.to = to;
const model = nonEmpty(state.payloadTemplate.model);
if (model) payload.model = model;
const thinking = nonEmpty(state.payloadTemplate.thinking);
if (thinking) payload.thinking = thinking;
const timeoutSeconds = parseOptionalPositiveInteger(state.payloadTemplate.timeoutSeconds);
if (timeoutSeconds != null) payload.timeoutSeconds = timeoutSeconds;
const explicitSessionKey = nonEmpty(state.payloadTemplate.sessionKey);
if (explicitSessionKey) {
payload.sessionKey = explicitSessionKey;
} else if (includeSessionKey) {
payload.sessionKey = state.sessionKey;
}
return payload;
}
function buildLegacyWebhookBody(input: {
state: OpenClawExecutionState;
context: AdapterExecutionContext["context"];
}): Record<string, unknown> {
const { state, context } = input;
const templateText = nonEmpty(state.payloadTemplate.text);
const payloadText = templateText ? appendWakeText(templateText, state.wakeText) : state.wakeText;
return {
...state.payloadTemplate,
stream: false,
sessionKey: state.sessionKey,
text: payloadText,
paperclip: {
...state.wakePayload,
sessionKey: state.sessionKey,
streamTransport: "webhook",
env: state.paperclipEnv,
context,
},
};
}
function buildWebhookBody(input: {
endpointKind: OpenClawEndpointKind;
state: OpenClawExecutionState;
context: AdapterExecutionContext["context"];
configModel: unknown;
includeHookSessionKey: boolean;
}): Record<string, unknown> {
const { endpointKind, state, context, configModel, includeHookSessionKey } = input;
if (endpointKind === "open_responses") {
return buildOpenResponsesWebhookBody({ state, configModel });
}
if (endpointKind === "hook_wake") {
return buildHookWakeBody(state);
}
if (endpointKind === "hook_agent") {
return buildHookAgentBody({ state, includeSessionKey: includeHookSessionKey });
}
return buildLegacyWebhookBody({ state, context });
}
async function sendWebhookRequest(params: {
url: string;
method: string;
headers: Record<string, string>;
payload: Record<string, unknown>;
onLog: AdapterExecutionContext["onLog"];
signal: AbortSignal;
}): Promise<{ response: Response; responseText: string }> {
const response = await sendJsonRequest({
url: params.url,
method: params.method,
headers: params.headers,
payload: params.payload,
signal: params.signal,
});
const responseText = await readAndLogResponseText({ response, onLog: params.onLog });
return { response, responseText };
}
export async function executeWebhook(ctx: AdapterExecutionContext, url: string): Promise<AdapterExecutionResult> {
const { onLog, onMeta, context } = ctx;
const state = buildExecutionState(ctx);
const originalUrl = url;
const originalEndpointKind = resolveEndpointKind(originalUrl);
let targetUrl = originalUrl;
let endpointKind = resolveEndpointKind(targetUrl);
const remappedFromResponses = originalEndpointKind === "open_responses";
// In webhook mode, /v1/responses is legacy wiring. Prefer hooks/agent.
if (remappedFromResponses) {
const rewritten = deriveHookAgentUrlFromResponses(targetUrl);
if (rewritten) {
await onLog(
"stdout",
`[openclaw] webhook transport selected; remapping ${targetUrl} -> ${rewritten}\n`,
);
targetUrl = rewritten;
endpointKind = resolveEndpointKind(targetUrl);
}
}
const headers = { ...state.headers };
if (endpointKind === "open_responses" && !headers["x-openclaw-session-key"] && !headers["X-OpenClaw-Session-Key"]) {
headers["x-openclaw-session-key"] = state.sessionKey;
}
if (onMeta) {
await onMeta({
adapterType: "openclaw",
command: "webhook",
commandArgs: [state.method, targetUrl],
context,
});
}
const includeHookSessionKey = asBooleanFlag(ctx.config.hookIncludeSessionKey, false);
const webhookBody = buildWebhookBody({
endpointKind,
state,
context,
configModel: ctx.config.model,
includeHookSessionKey,
});
const wakeCompatibilityBody = buildWakeCompatibilityPayload(state.wakeText);
const preferWakeCompatibilityBody = endpointKind === "hook_wake";
const initialBody = webhookBody;
const outboundHeaderKeys = Object.keys(headers).sort();
await onLog(
"stdout",
`[openclaw] outbound headers (redacted): ${stringifyForLog(redactForLog(headers), 4_000)}\n`,
);
await onLog(
"stdout",
`[openclaw] outbound payload (redacted): ${stringifyForLog(redactForLog(initialBody), 12_000)}\n`,
);
await onLog("stdout", `[openclaw] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`);
await onLog("stdout", `[openclaw] invoking ${state.method} ${targetUrl} (transport=webhook kind=${endpointKind})\n`);
if (preferWakeCompatibilityBody) {
await onLog("stdout", "[openclaw] using webhook wake payload for /hooks/wake\n");
}
const controller = new AbortController();
const timeout = state.timeoutSec > 0 ? setTimeout(() => controller.abort(), state.timeoutSec * 1000) : null;
try {
const initialResponse = await sendWebhookRequest({
url: targetUrl,
method: state.method,
headers,
payload: initialBody,
onLog,
signal: controller.signal,
});
let activeResponse = initialResponse;
let activeEndpointKind = endpointKind;
let activeUrl = targetUrl;
let activeHeaders = headers;
let usedLegacyResponsesFallback = false;
if (
remappedFromResponses &&
targetUrl !== originalUrl &&
initialResponse.response.status === 404
) {
await onLog(
"stdout",
`[openclaw] remapped hook endpoint returned 404; retrying legacy endpoint ${originalUrl}\n`,
);
activeEndpointKind = originalEndpointKind;
activeUrl = originalUrl;
usedLegacyResponsesFallback = true;
const fallbackHeaders = { ...state.headers };
if (
activeEndpointKind === "open_responses" &&
!fallbackHeaders["x-openclaw-session-key"] &&
!fallbackHeaders["X-OpenClaw-Session-Key"]
) {
fallbackHeaders["x-openclaw-session-key"] = state.sessionKey;
}
const fallbackBody = buildWebhookBody({
endpointKind: activeEndpointKind,
state,
context,
configModel: ctx.config.model,
includeHookSessionKey,
});
await onLog(
"stdout",
`[openclaw] fallback headers (redacted): ${stringifyForLog(redactForLog(fallbackHeaders), 4_000)}\n`,
);
await onLog(
"stdout",
`[openclaw] fallback payload (redacted): ${stringifyForLog(redactForLog(fallbackBody), 12_000)}\n`,
);
await onLog(
"stdout",
`[openclaw] invoking fallback ${state.method} ${activeUrl} (transport=webhook kind=${activeEndpointKind})\n`,
);
activeResponse = await sendWebhookRequest({
url: activeUrl,
method: state.method,
headers: fallbackHeaders,
payload: fallbackBody,
onLog,
signal: controller.signal,
});
activeHeaders = fallbackHeaders;
}
if (!activeResponse.response.ok) {
const canRetryWithWakeCompatibility =
(activeEndpointKind === "open_responses" || activeEndpointKind === "generic") &&
isWakeCompatibilityRetryableResponse(activeResponse.responseText);
if (canRetryWithWakeCompatibility) {
await onLog(
"stdout",
"[openclaw] endpoint requires text payload; retrying with wake compatibility format\n",
);
const retryResponse = await sendWebhookRequest({
url: activeUrl,
method: state.method,
headers: activeHeaders,
payload: wakeCompatibilityBody,
onLog,
signal: controller.signal,
});
if (retryResponse.response.ok) {
return {
exitCode: 0,
signal: null,
timedOut: false,
provider: "openclaw",
model: null,
summary: `OpenClaw webhook ${state.method} ${activeUrl} (wake compatibility)`,
resultJson: {
status: retryResponse.response.status,
statusText: retryResponse.response.statusText,
compatibilityMode: "wake_text",
usedLegacyResponsesFallback,
response: parseOpenClawResponse(retryResponse.responseText) ?? retryResponse.responseText,
},
};
}
return {
exitCode: 1,
signal: null,
timedOut: false,
errorMessage:
isTextRequiredResponse(retryResponse.responseText)
? "OpenClaw endpoint rejected the wake compatibility payload as text-required."
: `OpenClaw webhook failed with status ${retryResponse.response.status}`,
errorCode: isTextRequiredResponse(retryResponse.responseText)
? "openclaw_text_required"
: "openclaw_http_error",
resultJson: {
status: retryResponse.response.status,
statusText: retryResponse.response.statusText,
compatibilityMode: "wake_text",
response: parseOpenClawResponse(retryResponse.responseText) ?? retryResponse.responseText,
},
};
}
return {
exitCode: 1,
signal: null,
timedOut: false,
errorMessage:
isTextRequiredResponse(activeResponse.responseText)
? "OpenClaw endpoint rejected the payload as text-required."
: `OpenClaw webhook failed with status ${activeResponse.response.status}`,
errorCode: isTextRequiredResponse(activeResponse.responseText)
? "openclaw_text_required"
: "openclaw_http_error",
resultJson: {
status: activeResponse.response.status,
statusText: activeResponse.response.statusText,
response: parseOpenClawResponse(activeResponse.responseText) ?? activeResponse.responseText,
},
};
}
return {
exitCode: 0,
signal: null,
timedOut: false,
provider: "openclaw",
model: null,
summary: `OpenClaw webhook ${state.method} ${activeUrl}`,
resultJson: {
status: activeResponse.response.status,
statusText: activeResponse.response.statusText,
usedLegacyResponsesFallback,
response: parseOpenClawResponse(activeResponse.responseText) ?? activeResponse.responseText,
},
};
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
const timeoutMessage =
state.timeoutSec > 0
? `[openclaw] webhook request timed out after ${state.timeoutSec}s\n`
: "[openclaw] webhook request aborted\n";
await onLog("stderr", timeoutMessage);
return {
exitCode: null,
signal: null,
timedOut: true,
errorMessage: state.timeoutSec > 0 ? `Timed out after ${state.timeoutSec}s` : "Request aborted",
errorCode: "openclaw_webhook_timeout",
};
}
const message = err instanceof Error ? err.message : String(err);
await onLog("stderr", `[openclaw] request failed: ${message}\n`);
return {
exitCode: 1,
signal: null,
timedOut: false,
errorMessage: message,
errorCode: "openclaw_request_failed",
};
} finally {
if (timeout) clearTimeout(timeout);
}
}