fix(openclaw): parse wake metadata from gateway responses

This commit is contained in:
GeonWoo Jeon (Jay)
2026-04-07 22:49:23 +09:00
parent 9a9b5be518
commit 4241227227
3 changed files with 183 additions and 7 deletions
+108
View File
@@ -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",
})
})
})
+71 -7
View File
@@ -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<string, unknown> | null {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null
}
function firstStringValue(record: Record<string, unknown>, 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<WakeResult, "messageId" | "platform" | "channelId" | "threadId"> {
const record = asRecord(payload)
if (!record) return {}
const nestedCandidates = [record, asRecord(record.data), asRecord(record.result), asRecord(record.message)]
.filter((candidate): candidate is Record<string, unknown> => candidate !== null)
let bestMatch: Pick<WakeResult, "messageId" | "platform" | "channelId" | "threadId"> = {}
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<WakeResult, "messageId" | "platform" | "channelId" | "threadId"> {
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<WakeResult> {
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<string, string | undefined>,
): Promise<{ gateway: string; success: boolean; error?: string }> {
): Promise<WakeResult> {
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<typeof setTimeout> | undefined
const timeoutPromise = new Promise<never>((_, 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,
+4
View File
@@ -49,4 +49,8 @@ export interface WakeResult {
success: boolean
error?: string
statusCode?: number
messageId?: string
platform?: string
channelId?: string
threadId?: string
}