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,133 @@
|
||||
import { basename } from "path"
|
||||
import { resolveGateway } from "./config"
|
||||
import {
|
||||
wakeGateway,
|
||||
wakeCommandGateway,
|
||||
interpolateInstruction,
|
||||
} from "./dispatcher"
|
||||
import { getCurrentTmuxSession, captureTmuxPane } from "./tmux"
|
||||
import { startReplyListener, stopReplyListener } from "./reply-listener"
|
||||
import type { OpenClawConfig, OpenClawContext, OpenClawPayload, WakeResult } from "./types"
|
||||
|
||||
const DEBUG = process.env.OMX_OPENCLAW_DEBUG === "1"
|
||||
|
||||
function buildWhitelistedContext(context: OpenClawContext): OpenClawContext {
|
||||
const result: OpenClawContext = {}
|
||||
if (context.sessionId !== undefined) result.sessionId = context.sessionId
|
||||
if (context.projectPath !== undefined) result.projectPath = context.projectPath
|
||||
if (context.tmuxSession !== undefined) result.tmuxSession = context.tmuxSession
|
||||
if (context.prompt !== undefined) result.prompt = context.prompt
|
||||
if (context.contextSummary !== undefined) result.contextSummary = context.contextSummary
|
||||
if (context.reason !== undefined) result.reason = context.reason
|
||||
if (context.question !== undefined) result.question = context.question
|
||||
if (context.tmuxTail !== undefined) result.tmuxTail = context.tmuxTail
|
||||
if (context.replyChannel !== undefined) result.replyChannel = context.replyChannel
|
||||
if (context.replyTarget !== undefined) result.replyTarget = context.replyTarget
|
||||
if (context.replyThread !== undefined) result.replyThread = context.replyThread
|
||||
return result
|
||||
}
|
||||
|
||||
export async function wakeOpenClaw(
|
||||
config: OpenClawConfig,
|
||||
event: string,
|
||||
context: OpenClawContext,
|
||||
): Promise<WakeResult | null> {
|
||||
try {
|
||||
if (!config.enabled) return null
|
||||
|
||||
const resolved = resolveGateway(config, event)
|
||||
if (!resolved) return null
|
||||
|
||||
const { gatewayName, gateway, instruction } = resolved
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const replyChannel = context.replyChannel ?? process.env.OPENCLAW_REPLY_CHANNEL
|
||||
const replyTarget = context.replyTarget ?? process.env.OPENCLAW_REPLY_TARGET
|
||||
const replyThread = context.replyThread ?? process.env.OPENCLAW_REPLY_THREAD
|
||||
|
||||
const enrichedContext: OpenClawContext = {
|
||||
...context,
|
||||
...(replyChannel !== undefined && { replyChannel }),
|
||||
...(replyTarget !== undefined && { replyTarget }),
|
||||
...(replyThread !== undefined && { replyThread }),
|
||||
}
|
||||
|
||||
const tmuxSession = enrichedContext.tmuxSession ?? getCurrentTmuxSession() ?? undefined
|
||||
|
||||
let tmuxTail = enrichedContext.tmuxTail
|
||||
if (!tmuxTail && (event === "stop" || event === "session-end") && process.env.TMUX) {
|
||||
try {
|
||||
const paneId = process.env.TMUX_PANE
|
||||
if (paneId) {
|
||||
tmuxTail = (await captureTmuxPane(paneId, 15)) ?? undefined
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
const variables: Record<string, string | undefined> = {
|
||||
sessionId: enrichedContext.sessionId,
|
||||
projectPath: enrichedContext.projectPath,
|
||||
projectName: enrichedContext.projectPath ? basename(enrichedContext.projectPath) : undefined,
|
||||
tmuxSession,
|
||||
prompt: enrichedContext.prompt,
|
||||
contextSummary: enrichedContext.contextSummary,
|
||||
reason: enrichedContext.reason,
|
||||
question: enrichedContext.question,
|
||||
tmuxTail,
|
||||
event,
|
||||
timestamp: now,
|
||||
replyChannel,
|
||||
replyTarget,
|
||||
replyThread,
|
||||
}
|
||||
|
||||
const interpolatedInstruction = interpolateInstruction(instruction, variables)
|
||||
variables.instruction = interpolatedInstruction
|
||||
|
||||
let result: WakeResult
|
||||
|
||||
if (gateway.type === "command") {
|
||||
result = await wakeCommandGateway(gatewayName, gateway, variables)
|
||||
} else {
|
||||
const payload: OpenClawPayload = {
|
||||
event,
|
||||
instruction: interpolatedInstruction,
|
||||
text: interpolatedInstruction,
|
||||
timestamp: now,
|
||||
sessionId: enrichedContext.sessionId,
|
||||
projectPath: enrichedContext.projectPath,
|
||||
projectName: enrichedContext.projectPath ? basename(enrichedContext.projectPath) : undefined,
|
||||
tmuxSession,
|
||||
tmuxTail,
|
||||
...(replyChannel !== undefined && { channel: replyChannel }),
|
||||
...(replyTarget !== undefined && { to: replyTarget }),
|
||||
...(replyThread !== undefined && { threadId: replyThread }),
|
||||
context: buildWhitelistedContext(enrichedContext),
|
||||
}
|
||||
|
||||
result = await wakeGateway(gatewayName, gateway, payload)
|
||||
}
|
||||
|
||||
if (DEBUG) {
|
||||
console.error(`[openclaw] wake ${event} -> ${gatewayName}: ${result.success ? "ok" : result.error}`)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
if (DEBUG) {
|
||||
console.error(`[openclaw] wakeOpenClaw error:`, error instanceof Error ? error.message : error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function initializeOpenClaw(config: OpenClawConfig): Promise<void> {
|
||||
if (config.enabled && (config.discordBotToken || config.telegramBotToken)) {
|
||||
await startReplyListener(config)
|
||||
}
|
||||
}
|
||||
|
||||
export { startReplyListener, stopReplyListener }
|
||||
Reference in New Issue
Block a user