feat: port OpenClaw bidirectional integration from omx
Ports the complete OpenClaw integration system from oh-my-codex: Outbound (opencode→OpenClaw): - wakeOpenClaw() fire-and-forget gateway notifications - HTTP and command gateway dispatchers - Template variable interpolation - Config from oh-my-opencode.jsonc (no env gate needed) Inbound (OpenClaw→opencode): - Reply listener daemon (Discord/Telegram polling) - Session registry for message↔tmux pane correlation - Tmux pane detection, content capture, and text injection - Input sanitization and rate limiting - Pane verification before injection Files: - src/openclaw/ (types, config, dispatcher, index, reply-listener, session-registry, tmux, daemon) - src/config/schema/openclaw.ts (Zod v4 schema) - src/hooks/openclaw.ts (session hook) - Tests: 12 pass (config + dispatcher)
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { resolveGateway, validateGatewayUrl, normalizeReplyListenerConfig } from "../config"
|
||||
import type { OpenClawConfig } from "../types"
|
||||
|
||||
describe("OpenClaw Config", () => {
|
||||
test("resolveGateway resolves HTTP gateway", () => {
|
||||
const config: OpenClawConfig = {
|
||||
enabled: true,
|
||||
gateways: {
|
||||
discord: {
|
||||
type: "http",
|
||||
url: "https://discord.com/api/webhooks/123",
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
"session-start": {
|
||||
enabled: true,
|
||||
gateway: "discord",
|
||||
instruction: "Started session {{sessionId}}",
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
const resolved = resolveGateway(config, "session-start")
|
||||
expect(resolved).not.toBeNull()
|
||||
expect(resolved?.gatewayName).toBe("discord")
|
||||
expect(resolved?.gateway.url).toBe("https://discord.com/api/webhooks/123")
|
||||
expect(resolved?.instruction).toBe("Started session {{sessionId}}")
|
||||
})
|
||||
|
||||
test("resolveGateway returns null for disabled config", () => {
|
||||
const config: OpenClawConfig = {
|
||||
enabled: false,
|
||||
gateways: {},
|
||||
hooks: {},
|
||||
} as any
|
||||
expect(resolveGateway(config, "session-start")).toBeNull()
|
||||
})
|
||||
|
||||
test("resolveGateway returns null for unknown hook", () => {
|
||||
const config: OpenClawConfig = {
|
||||
enabled: true,
|
||||
gateways: {},
|
||||
hooks: {},
|
||||
} as any
|
||||
expect(resolveGateway(config, "unknown")).toBeNull()
|
||||
})
|
||||
|
||||
test("resolveGateway returns null for disabled hook", () => {
|
||||
const config: OpenClawConfig = {
|
||||
enabled: true,
|
||||
gateways: { g: { url: "https://example.com" } },
|
||||
hooks: {
|
||||
event: { enabled: false, gateway: "g", instruction: "i" },
|
||||
},
|
||||
} as any
|
||||
expect(resolveGateway(config, "event")).toBeNull()
|
||||
})
|
||||
|
||||
test("validateGatewayUrl allows HTTPS", () => {
|
||||
expect(validateGatewayUrl("https://example.com")).toBe(true)
|
||||
})
|
||||
|
||||
test("validateGatewayUrl rejects HTTP remote", () => {
|
||||
expect(validateGatewayUrl("http://example.com")).toBe(false)
|
||||
})
|
||||
|
||||
test("validateGatewayUrl allows HTTP localhost", () => {
|
||||
expect(validateGatewayUrl("http://localhost:3000")).toBe(true)
|
||||
expect(validateGatewayUrl("http://127.0.0.1:3000")).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test, mock, spyOn } from "bun:test"
|
||||
import {
|
||||
interpolateInstruction,
|
||||
shellEscapeArg,
|
||||
wakeGateway,
|
||||
wakeCommandGateway,
|
||||
} from "../dispatcher"
|
||||
|
||||
describe("OpenClaw Dispatcher", () => {
|
||||
test("interpolateInstruction replaces variables", () => {
|
||||
const template = "Hello {{name}}, welcome to {{place}}!"
|
||||
const variables = { name: "World", place: "Bun" }
|
||||
expect(interpolateInstruction(template, variables)).toBe(
|
||||
"Hello World, welcome to Bun!",
|
||||
)
|
||||
})
|
||||
|
||||
test("interpolateInstruction handles missing variables", () => {
|
||||
const template = "Hello {{name}}!"
|
||||
const variables = {}
|
||||
expect(interpolateInstruction(template, variables)).toBe("Hello !")
|
||||
})
|
||||
|
||||
test("shellEscapeArg escapes single quotes", () => {
|
||||
expect(shellEscapeArg("foo'bar")).toBe("'foo'\\''bar'")
|
||||
expect(shellEscapeArg("simple")).toBe("'simple'")
|
||||
})
|
||||
|
||||
test("wakeGateway sends POST request", async () => {
|
||||
const fetchSpy = spyOn(global, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
||||
)
|
||||
|
||||
const result = await wakeGateway(
|
||||
"test",
|
||||
{ url: "https://example.com", method: "POST", timeout: 1000, type: "http" },
|
||||
{ foo: "bar" },
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(fetchSpy).toHaveBeenCalled()
|
||||
const call = fetchSpy.mock.calls[0]
|
||||
expect(call[0]).toBe("https://example.com")
|
||||
expect(call[1]?.method).toBe("POST")
|
||||
expect(call[1]?.body).toBe('{"foo":"bar"}')
|
||||
|
||||
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)
|
||||
expect(result.error).toContain("Invalid URL")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user