fix(openclaw): support /hooks/wake compatibility payload
This commit is contained in:
@@ -17,6 +17,8 @@ Don't use when:
|
|||||||
|
|
||||||
Core fields:
|
Core fields:
|
||||||
- url (string, required): OpenClaw webhook endpoint URL
|
- url (string, required): OpenClaw webhook endpoint URL
|
||||||
|
- If the URL path is \`/hooks/wake\`, Paperclip uses OpenClaw compatibility payload (\`{ text, mode }\`).
|
||||||
|
- For full structured Paperclip context payloads, use a mapped endpoint (for example \`/hooks/paperclip\`).
|
||||||
- method (string, optional): HTTP method, default POST
|
- method (string, optional): HTTP method, default POST
|
||||||
- headers (object, optional): extra HTTP headers for webhook calls
|
- headers (object, optional): extra HTTP headers for webhook calls
|
||||||
- webhookAuthHeader (string, optional): Authorization header value if your endpoint requires auth
|
- webhookAuthHeader (string, optional): Authorization header value if your endpoint requires auth
|
||||||
|
|||||||
@@ -6,6 +6,82 @@ function nonEmpty(value: unknown): string | null {
|
|||||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldUseWakeTextPayload(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const path = parsed.pathname.toLowerCase();
|
||||||
|
return path === "/hooks/wake" || path.endsWith("/hooks/wake");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWakeText(payload: {
|
||||||
|
runId: string;
|
||||||
|
agentId: string;
|
||||||
|
companyId: string;
|
||||||
|
taskId: string | null;
|
||||||
|
issueId: string | null;
|
||||||
|
wakeReason: string | null;
|
||||||
|
wakeCommentId: string | null;
|
||||||
|
approvalId: string | null;
|
||||||
|
approvalStatus: string | null;
|
||||||
|
issueIds: string[];
|
||||||
|
}): string {
|
||||||
|
const lines = [
|
||||||
|
"Paperclip wake event.",
|
||||||
|
"",
|
||||||
|
`runId: ${payload.runId}`,
|
||||||
|
`agentId: ${payload.agentId}`,
|
||||||
|
`companyId: ${payload.companyId}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (payload.taskId) lines.push(`taskId: ${payload.taskId}`);
|
||||||
|
if (payload.issueId) lines.push(`issueId: ${payload.issueId}`);
|
||||||
|
if (payload.wakeReason) lines.push(`wakeReason: ${payload.wakeReason}`);
|
||||||
|
if (payload.wakeCommentId) lines.push(`wakeCommentId: ${payload.wakeCommentId}`);
|
||||||
|
if (payload.approvalId) lines.push(`approvalId: ${payload.approvalId}`);
|
||||||
|
if (payload.approvalStatus) lines.push(`approvalStatus: ${payload.approvalStatus}`);
|
||||||
|
if (payload.issueIds.length > 0) lines.push(`issueIds: ${payload.issueIds.join(",")}`);
|
||||||
|
|
||||||
|
lines.push("", "Run your Paperclip heartbeat procedure now.");
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextRequiredResponse(responseText: string): boolean {
|
||||||
|
const parsed = parseOpenClawResponse(responseText);
|
||||||
|
const parsedError = parsed && typeof parsed.error === "string" ? parsed.error : null;
|
||||||
|
if (parsedError && parsedError.toLowerCase().includes("text required")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return responseText.toLowerCase().includes("text required");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 fetch(params.url, {
|
||||||
|
method: params.method,
|
||||||
|
headers: params.headers,
|
||||||
|
body: JSON.stringify(params.payload),
|
||||||
|
signal: params.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseText = await response.text();
|
||||||
|
if (responseText.trim().length > 0) {
|
||||||
|
await params.onLog("stdout", `[openclaw] response (${response.status}) ${responseText.slice(0, 2000)}\n`);
|
||||||
|
} else {
|
||||||
|
await params.onLog("stdout", `[openclaw] response (${response.status}) <empty>\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { response, responseText };
|
||||||
|
}
|
||||||
|
|
||||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||||
const { config, runId, agent, context, onLog, onMeta } = ctx;
|
const { config, runId, agent, context, onLog, onMeta } = ctx;
|
||||||
const url = asString(config.url, "").trim();
|
const url = asString(config.url, "").trim();
|
||||||
@@ -52,13 +128,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
: [],
|
: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const body = {
|
const paperclipBody = {
|
||||||
...payloadTemplate,
|
...payloadTemplate,
|
||||||
paperclip: {
|
paperclip: {
|
||||||
...wakePayload,
|
...wakePayload,
|
||||||
context,
|
context,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const wakeTextBody = {
|
||||||
|
text: buildWakeText(wakePayload),
|
||||||
|
mode: "now",
|
||||||
|
};
|
||||||
|
|
||||||
if (onMeta) {
|
if (onMeta) {
|
||||||
await onMeta({
|
await onMeta({
|
||||||
@@ -75,21 +155,69 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
const timeout = setTimeout(() => controller.abort(), timeoutSec * 1000);
|
const timeout = setTimeout(() => controller.abort(), timeoutSec * 1000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const preferWakeTextPayload = shouldUseWakeTextPayload(url);
|
||||||
|
if (preferWakeTextPayload) {
|
||||||
|
await onLog("stdout", "[openclaw] using wake text payload for /hooks/wake compatibility\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialPayload = preferWakeTextPayload ? wakeTextBody : paperclipBody;
|
||||||
|
|
||||||
|
const { response, responseText } = await sendWebhookRequest({
|
||||||
|
url,
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(body),
|
payload: initialPayload,
|
||||||
|
onLog,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
const responseText = await response.text();
|
|
||||||
if (responseText.trim().length > 0) {
|
|
||||||
await onLog("stdout", `[openclaw] response (${response.status}) ${responseText.slice(0, 2000)}\n`);
|
|
||||||
} else {
|
|
||||||
await onLog("stdout", `[openclaw] response (${response.status}) <empty>\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
const canRetryWithWakeText = !preferWakeTextPayload && isTextRequiredResponse(responseText);
|
||||||
|
|
||||||
|
if (canRetryWithWakeText) {
|
||||||
|
await onLog("stdout", "[openclaw] endpoint requires text payload; retrying with wake compatibility format\n");
|
||||||
|
|
||||||
|
const retry = await sendWebhookRequest({
|
||||||
|
url,
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
payload: wakeTextBody,
|
||||||
|
onLog,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (retry.response.ok) {
|
||||||
|
return {
|
||||||
|
exitCode: 0,
|
||||||
|
signal: null,
|
||||||
|
timedOut: false,
|
||||||
|
provider: "openclaw",
|
||||||
|
model: null,
|
||||||
|
summary: `OpenClaw webhook ${method} ${url} (wake compatibility)`,
|
||||||
|
resultJson: {
|
||||||
|
status: retry.response.status,
|
||||||
|
statusText: retry.response.statusText,
|
||||||
|
compatibilityMode: "wake_text",
|
||||||
|
response: parseOpenClawResponse(retry.responseText) ?? retry.responseText,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
exitCode: 1,
|
||||||
|
signal: null,
|
||||||
|
timedOut: false,
|
||||||
|
errorMessage: `OpenClaw webhook failed with status ${retry.response.status}`,
|
||||||
|
errorCode: "openclaw_http_error",
|
||||||
|
resultJson: {
|
||||||
|
status: retry.response.status,
|
||||||
|
statusText: retry.response.statusText,
|
||||||
|
compatibilityMode: "wake_text",
|
||||||
|
response: parseOpenClawResponse(retry.responseText) ?? retry.responseText,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
exitCode: 1,
|
exitCode: 1,
|
||||||
signal: null,
|
signal: null,
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ function normalizeHostname(value: string | null | undefined): string | null {
|
|||||||
return trimmed.toLowerCase();
|
return trimmed.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isWakePath(pathname: string): boolean {
|
||||||
|
const value = pathname.trim().toLowerCase();
|
||||||
|
return value === "/hooks/wake" || value.endsWith("/hooks/wake");
|
||||||
|
}
|
||||||
|
|
||||||
function pushDeploymentDiagnostics(
|
function pushDeploymentDiagnostics(
|
||||||
checks: AdapterEnvironmentCheck[],
|
checks: AdapterEnvironmentCheck[],
|
||||||
ctx: AdapterEnvironmentTestContext,
|
ctx: AdapterEnvironmentTestContext,
|
||||||
@@ -148,6 +153,15 @@ export async function testEnvironment(
|
|||||||
hint: "Use a reachable hostname/IP (for example Tailscale/private hostname or public domain).",
|
hint: "Use a reachable hostname/IP (for example Tailscale/private hostname or public domain).",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isWakePath(url.pathname)) {
|
||||||
|
checks.push({
|
||||||
|
code: "openclaw_wake_endpoint_compat_mode",
|
||||||
|
level: "info",
|
||||||
|
message: "Endpoint targets /hooks/wake; adapter will use OpenClaw wake compatibility payload (text/mode).",
|
||||||
|
hint: "For structured Paperclip JSON payloads, use a mapped webhook endpoint such as /hooks/paperclip.",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pushDeploymentDiagnostics(checks, ctx, url);
|
pushDeploymentDiagnostics(checks, ctx, url);
|
||||||
|
|||||||
137
server/src/__tests__/openclaw-adapter.test.ts
Normal file
137
server/src/__tests__/openclaw-adapter.test.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { execute, testEnvironment } from "@paperclipai/adapter-openclaw/server";
|
||||||
|
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||||
|
|
||||||
|
function buildContext(config: Record<string, unknown>): AdapterExecutionContext {
|
||||||
|
return {
|
||||||
|
runId: "run-123",
|
||||||
|
agent: {
|
||||||
|
id: "agent-123",
|
||||||
|
companyId: "company-123",
|
||||||
|
name: "OpenClaw Agent",
|
||||||
|
adapterType: "openclaw",
|
||||||
|
adapterConfig: {},
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
sessionId: null,
|
||||||
|
sessionParams: null,
|
||||||
|
sessionDisplayId: null,
|
||||||
|
taskKey: null,
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
context: {
|
||||||
|
taskId: "task-123",
|
||||||
|
issueId: "issue-123",
|
||||||
|
wakeReason: "issue_assigned",
|
||||||
|
issueIds: ["issue-123"],
|
||||||
|
},
|
||||||
|
onLog: async () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("openclaw adapter execute", () => {
|
||||||
|
it("sends structured paperclip payload to mapped endpoints", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ ok: true }), { status: 200, statusText: "OK" }),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const result = await execute(
|
||||||
|
buildContext({
|
||||||
|
url: "https://agent.example/hooks/paperclip",
|
||||||
|
method: "POST",
|
||||||
|
payloadTemplate: { foo: "bar" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record<string, unknown>;
|
||||||
|
expect(body.foo).toBe("bar");
|
||||||
|
expect(body.paperclip).toBeTypeOf("object");
|
||||||
|
expect((body.paperclip as Record<string, unknown>).runId).toBe("run-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses wake text payload for /hooks/wake endpoints", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ ok: true }), { status: 200, statusText: "OK" }),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const result = await execute(
|
||||||
|
buildContext({
|
||||||
|
url: "https://agent.example/hooks/wake",
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record<string, unknown>;
|
||||||
|
expect(body.mode).toBe("now");
|
||||||
|
expect(typeof body.text).toBe("string");
|
||||||
|
expect(body.paperclip).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries with wake text payload when endpoint reports text required", async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ ok: false, error: "text required" }), {
|
||||||
|
status: 400,
|
||||||
|
statusText: "Bad Request",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ ok: true }), { status: 200, statusText: "OK" }),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const result = await execute(
|
||||||
|
buildContext({
|
||||||
|
url: "https://agent.example/hooks/paperclip",
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
const firstBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record<string, unknown>;
|
||||||
|
expect(firstBody.paperclip).toBeTypeOf("object");
|
||||||
|
|
||||||
|
const secondBody = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body ?? "{}")) as Record<string, unknown>;
|
||||||
|
expect(secondBody.mode).toBe("now");
|
||||||
|
expect(typeof secondBody.text).toBe("string");
|
||||||
|
expect(result.resultJson?.compatibilityMode).toBe("wake_text");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("openclaw adapter environment checks", () => {
|
||||||
|
it("reports compatibility mode info for /hooks/wake endpoints", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 405, statusText: "Method Not Allowed" }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const result = await testEnvironment({
|
||||||
|
companyId: "company-123",
|
||||||
|
adapterType: "openclaw",
|
||||||
|
config: {
|
||||||
|
url: "https://agent.example/hooks/wake",
|
||||||
|
},
|
||||||
|
deployment: {
|
||||||
|
mode: "authenticated",
|
||||||
|
exposure: "private",
|
||||||
|
bindHost: "paperclip.internal",
|
||||||
|
allowedHostnames: ["paperclip.internal"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const compatibilityCheck = result.checks.find((check) => check.code === "openclaw_wake_endpoint_compat_mode");
|
||||||
|
expect(compatibilityCheck?.level).toBe("info");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user