From 4241227227980675a866ff5ce0bee6c5f8656cde Mon Sep 17 00:00:00 2001 From: "GeonWoo Jeon (Jay)" Date: Tue, 7 Apr 2026 22:49:23 +0900 Subject: [PATCH] fix(openclaw): parse wake metadata from gateway responses --- src/openclaw/__tests__/dispatcher.test.ts | 108 ++++++++++++++++++++++ src/openclaw/dispatcher.ts | 78 ++++++++++++++-- src/openclaw/types.ts | 4 + 3 files changed, 183 insertions(+), 7 deletions(-) diff --git a/src/openclaw/__tests__/dispatcher.test.ts b/src/openclaw/__tests__/dispatcher.test.ts index 62a467abb..96bed7335 100644 --- a/src/openclaw/__tests__/dispatcher.test.ts +++ b/src/openclaw/__tests__/dispatcher.test.ts @@ -54,6 +54,75 @@ describe("OpenClaw Dispatcher", () => { } }) + test("wakeGateway returns correlation metadata from JSON response", async () => { + const fetchSpy = spyOn(global, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + data: { + messageId: "msg-123", + platform: "discord", + channelId: "chan-1", + threadId: "thread-9", + }, + }), + { status: 200 }, + ), + ) + + try { + const result = await wakeGateway( + "test", + { url: "https://example.com", method: "POST", timeout: 1000, type: "http" }, + { foo: "bar" }, + ) + + expect(result).toMatchObject({ + success: true, + messageId: "msg-123", + platform: "discord", + channelId: "chan-1", + threadId: "thread-9", + }) + } finally { + fetchSpy.mockRestore() + } + }) + + test("wakeGateway prefers nested message metadata over wrapper ids", async () => { + const fetchSpy = spyOn(global, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + id: "job-42", + data: { + messageId: "msg-123", + platform: "discord", + channelId: "chan-1", + threadId: "thread-9", + }, + }), + { status: 200 }, + ), + ) + + try { + const result = await wakeGateway( + "test", + { url: "https://example.com", method: "POST", timeout: 1000, type: "http" }, + { foo: "bar" }, + ) + + expect(result).toMatchObject({ + success: true, + messageId: "msg-123", + platform: "discord", + channelId: "chan-1", + threadId: "thread-9", + }) + } finally { + fetchSpy.mockRestore() + } + }) + test("wakeGateway fails on invalid URL", async () => { const result = await wakeGateway("test", { url: "http://example.com", method: "POST", timeout: 1000, type: "http" }, {}) expect(result.success).toBe(false) @@ -108,4 +177,43 @@ describe("OpenClaw Dispatcher", () => { killSpy.mockRestore() } }) + + test("wakeCommandGateway returns correlation metadata from stdout JSON", async () => { + const result = await wakeCommandGateway( + "command", + { + type: "command", + method: "POST", + command: "printf '%s' '{\"messageId\":\"55\",\"platform\":\"telegram\",\"threadId\":\"thr\"}'", + timeout: 1000, + }, + {}, + ) + + expect(result).toMatchObject({ + success: true, + messageId: "55", + platform: "telegram", + threadId: "thr", + }) + }) + + test("wakeCommandGateway returns correlation metadata from OpenClaw CLI stdout", async () => { + const result = await wakeCommandGateway( + "command", + { + type: "command", + method: "POST", + command: "printf '%s' '✅ Sent via Discord. Message ID: 55'", + timeout: 1000, + }, + {}, + ) + + expect(result).toMatchObject({ + success: true, + messageId: "55", + platform: "discord", + }) + }) }) diff --git a/src/openclaw/dispatcher.ts b/src/openclaw/dispatcher.ts index 75cab8c23..173819c93 100644 --- a/src/openclaw/dispatcher.ts +++ b/src/openclaw/dispatcher.ts @@ -1,5 +1,5 @@ import { spawn } from "bun" -import type { OpenClawGateway } from "./types" +import type { OpenClawGateway, WakeResult } from "./types" const DEFAULT_HTTP_TIMEOUT_MS = 10_000 const DEFAULT_COMMAND_TIMEOUT_MS = 5_000 @@ -66,11 +66,70 @@ export function resolveCommandTimeoutMs( ) } +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null ? (value as Record) : null +} + +function firstStringValue(record: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = record[key] + if (typeof value === "string" && value.trim().length > 0) return value + if (typeof value === "number" && Number.isFinite(value)) return String(value) + } + return undefined +} + +function extractWakeMetadata(payload: unknown): Pick { + const record = asRecord(payload) + if (!record) return {} + + const nestedCandidates = [record, asRecord(record.data), asRecord(record.result), asRecord(record.message)] + .filter((candidate): candidate is Record => candidate !== null) + + let bestMatch: Pick = {} + let bestScore = -1 + + for (const candidate of nestedCandidates) { + const messageId = firstStringValue(candidate, ["messageId", "message_id", "id"]) + const platform = firstStringValue(candidate, ["platform", "source"]) + const channelId = firstStringValue(candidate, ["channelId", "channel_id", "channel"]) + const threadId = firstStringValue(candidate, ["threadId", "thread_id", "thread"]) + + const score = + (messageId ? 4 : 0) + + (platform ? 3 : 0) + + (channelId ? 2 : 0) + + (threadId ? 1 : 0) + + if (score > bestScore) { + bestMatch = { messageId, platform, channelId, threadId } + bestScore = score + } + } + + return bestScore > 0 ? bestMatch : {} +} + +function parseWakeMetadata(raw: string): Pick { + const trimmed = raw.trim() + if (!trimmed) return {} + try { + return extractWakeMetadata(JSON.parse(trimmed)) + } catch { + const messageId = trimmed.match(/message\s+id:\s*([^\s]+)/i)?.[1] + const platform = trimmed.match(/sent\s+via\s+([a-z0-9_-]+)/i)?.[1]?.toLowerCase() + return { + ...(messageId ? { messageId } : {}), + ...(platform ? { platform } : {}), + } + } +} + export async function wakeGateway( gatewayName: string, gatewayConfig: OpenClawGateway, payload: unknown, -): Promise<{ gateway: string; success: boolean; error?: string; statusCode?: number }> { +): Promise { if (!gatewayConfig.url || !validateGatewayUrl(gatewayConfig.url)) { return { gateway: gatewayName, @@ -107,8 +166,10 @@ export async function wakeGateway( statusCode: response.status, } } - - return { gateway: gatewayName, success: true, statusCode: response.status } + + const metadata = parseWakeMetadata(await response.text()) + + return { gateway: gatewayName, success: true, statusCode: response.status, ...metadata } } catch (error) { return { gateway: gatewayName, @@ -122,7 +183,7 @@ export async function wakeCommandGateway( gatewayName: string, gatewayConfig: OpenClawGateway, variables: Record, -): Promise<{ gateway: string; success: boolean; error?: string }> { +): Promise { if (!gatewayConfig.command) { return { gateway: gatewayName, @@ -142,10 +203,11 @@ export async function wakeCommandGateway( const proc = spawn(["sh", "-c", interpolated], { env: { ...process.env }, - stdout: "ignore", + stdout: "pipe", stderr: "ignore", detached: process.platform !== "win32", }) + const stdoutPromise = new Response(proc.stdout).text() let timeoutId: ReturnType | undefined const timeoutPromise = new Promise((_, reject) => { @@ -167,7 +229,9 @@ export async function wakeCommandGateway( throw new Error(`Command exited with code ${proc.exitCode}`) } - return { gateway: gatewayName, success: true } + const metadata = parseWakeMetadata(await stdoutPromise) + + return { gateway: gatewayName, success: true, ...metadata } } catch (error) { return { gateway: gatewayName, diff --git a/src/openclaw/types.ts b/src/openclaw/types.ts index b05325da2..e29a5f201 100644 --- a/src/openclaw/types.ts +++ b/src/openclaw/types.ts @@ -49,4 +49,8 @@ export interface WakeResult { success: boolean error?: string statusCode?: number + messageId?: string + platform?: string + channelId?: string + threadId?: string }