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,172 @@
|
||||
import { spawn } from "bun"
|
||||
import type { OpenClawGateway } from "./types"
|
||||
|
||||
const DEFAULT_HTTP_TIMEOUT_MS = 10_000
|
||||
const DEFAULT_COMMAND_TIMEOUT_MS = 5_000
|
||||
const MIN_COMMAND_TIMEOUT_MS = 100
|
||||
const MAX_COMMAND_TIMEOUT_MS = 300_000
|
||||
const SHELL_METACHAR_RE = /[|&;><`$()]/
|
||||
|
||||
export function validateGatewayUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol === "https:") return true
|
||||
if (
|
||||
parsed.protocol === "http:" &&
|
||||
(parsed.hostname === "localhost" ||
|
||||
parsed.hostname === "127.0.0.1" ||
|
||||
parsed.hostname === "::1" ||
|
||||
parsed.hostname === "[::1]")
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function interpolateInstruction(
|
||||
template: string,
|
||||
variables: Record<string, string | undefined>,
|
||||
): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_match, key) => {
|
||||
return variables[key] ?? ""
|
||||
})
|
||||
}
|
||||
|
||||
export function shellEscapeArg(value: string): string {
|
||||
return "'" + value.replace(/'/g, "'\\''") + "'"
|
||||
}
|
||||
|
||||
export function resolveCommandTimeoutMs(
|
||||
gatewayTimeout?: number,
|
||||
envTimeoutRaw = process.env.OMX_OPENCLAW_COMMAND_TIMEOUT_MS,
|
||||
): number {
|
||||
const parseFinite = (value: unknown): number | undefined => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return undefined
|
||||
return value
|
||||
}
|
||||
const parseEnv = (value?: string): number | undefined => {
|
||||
if (!value) return undefined
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
|
||||
const rawTimeout =
|
||||
parseFinite(gatewayTimeout) ??
|
||||
parseEnv(envTimeoutRaw) ??
|
||||
DEFAULT_COMMAND_TIMEOUT_MS
|
||||
|
||||
return Math.min(
|
||||
MAX_COMMAND_TIMEOUT_MS,
|
||||
Math.max(MIN_COMMAND_TIMEOUT_MS, Math.trunc(rawTimeout)),
|
||||
)
|
||||
}
|
||||
|
||||
export async function wakeGateway(
|
||||
gatewayName: string,
|
||||
gatewayConfig: OpenClawGateway,
|
||||
payload: unknown,
|
||||
): Promise<{ gateway: string; success: boolean; error?: string; statusCode?: number }> {
|
||||
if (!gatewayConfig.url || !validateGatewayUrl(gatewayConfig.url)) {
|
||||
return {
|
||||
gateway: gatewayName,
|
||||
success: false,
|
||||
error: "Invalid URL (HTTPS required)",
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...gatewayConfig.headers,
|
||||
}
|
||||
|
||||
const timeout = gatewayConfig.timeout ?? DEFAULT_HTTP_TIMEOUT_MS
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||
|
||||
const response = await fetch(gatewayConfig.url, {
|
||||
method: gatewayConfig.method || "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
gateway: gatewayName,
|
||||
success: false,
|
||||
error: `HTTP ${response.status}`,
|
||||
statusCode: response.status,
|
||||
}
|
||||
}
|
||||
|
||||
return { gateway: gatewayName, success: true, statusCode: response.status }
|
||||
} catch (error) {
|
||||
return {
|
||||
gateway: gatewayName,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function wakeCommandGateway(
|
||||
gatewayName: string,
|
||||
gatewayConfig: OpenClawGateway,
|
||||
variables: Record<string, string | undefined>,
|
||||
): Promise<{ gateway: string; success: boolean; error?: string }> {
|
||||
if (!gatewayConfig.command) {
|
||||
return {
|
||||
gateway: gatewayName,
|
||||
success: false,
|
||||
error: "No command configured",
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const timeout = resolveCommandTimeoutMs(gatewayConfig.timeout)
|
||||
|
||||
// Interpolate variables with shell escaping
|
||||
let interpolated = gatewayConfig.command.replace(/\{\{(\w+)\}\}/g, (_match, key) => {
|
||||
const value = variables[key]
|
||||
if (value === undefined) return _match
|
||||
return shellEscapeArg(value)
|
||||
})
|
||||
|
||||
// Always use sh -c to handle the shell command string correctly
|
||||
const proc = spawn(["sh", "-c", interpolated], {
|
||||
env: { ...process.env },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
|
||||
// Handle timeout manually
|
||||
const timeoutPromise = new Promise<number>((_, reject) => {
|
||||
setTimeout(() => {
|
||||
proc.kill()
|
||||
reject(new Error("Command timed out"))
|
||||
}, timeout)
|
||||
})
|
||||
|
||||
await Promise.race([proc.exited, timeoutPromise])
|
||||
|
||||
if (proc.exitCode !== 0) {
|
||||
throw new Error(`Command exited with code ${proc.exitCode}`)
|
||||
}
|
||||
|
||||
return { gateway: gatewayName, success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
gateway: gatewayName,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user