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:
YeonGyu-Kim
2026-03-16 21:55:10 +09:00
parent 427fa6d7a2
commit b79df5e018
13 changed files with 1804 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
import type { OpenClawConfig, OpenClawGateway } from "./types"
const DEFAULT_REPLY_POLL_INTERVAL_MS = 3000
const MIN_REPLY_POLL_INTERVAL_MS = 500
const MAX_REPLY_POLL_INTERVAL_MS = 60000
const DEFAULT_REPLY_RATE_LIMIT_PER_MINUTE = 10
const MIN_REPLY_RATE_LIMIT_PER_MINUTE = 1
const DEFAULT_REPLY_MAX_MESSAGE_LENGTH = 500
const MIN_REPLY_MAX_MESSAGE_LENGTH = 1
const MAX_REPLY_MAX_MESSAGE_LENGTH = 4000
function normalizeInteger(
value: unknown,
fallback: number,
min: number,
max?: number,
): number {
const numeric =
typeof value === "number"
? Math.trunc(value)
: typeof value === "string" && value.trim()
? Number.parseInt(value, 10)
: Number.NaN
if (!Number.isFinite(numeric)) return fallback
if (numeric < min) return min
if (max !== undefined && numeric > max) return max
return numeric
}
export function normalizeReplyListenerConfig(config: OpenClawConfig): OpenClawConfig {
const discordEnabled =
config.discordBotToken && config.discordChannelId ? true : false
const telegramEnabled =
config.telegramBotToken && config.telegramChatId ? true : false
return {
...config,
discordBotToken: config.discordBotToken,
discordChannelId: config.discordChannelId,
telegramBotToken: config.telegramBotToken,
telegramChatId: config.telegramChatId,
pollIntervalMs: normalizeInteger(
config.pollIntervalMs,
DEFAULT_REPLY_POLL_INTERVAL_MS,
MIN_REPLY_POLL_INTERVAL_MS,
MAX_REPLY_POLL_INTERVAL_MS,
),
rateLimitPerMinute: normalizeInteger(
config.rateLimitPerMinute,
DEFAULT_REPLY_RATE_LIMIT_PER_MINUTE,
MIN_REPLY_RATE_LIMIT_PER_MINUTE,
),
maxMessageLength: normalizeInteger(
config.maxMessageLength,
DEFAULT_REPLY_MAX_MESSAGE_LENGTH,
MIN_REPLY_MAX_MESSAGE_LENGTH,
MAX_REPLY_MAX_MESSAGE_LENGTH,
),
includePrefix: config.includePrefix !== false,
authorizedDiscordUserIds: Array.isArray(config.authorizedDiscordUserIds)
? config.authorizedDiscordUserIds.filter(
(id) => typeof id === "string" && id.trim() !== "",
)
: [],
}
}
export function resolveGateway(
config: OpenClawConfig,
event: string,
): { gatewayName: string; gateway: OpenClawGateway; instruction: string } | null {
if (!config.enabled) return null
const mapping = config.hooks[event]
if (!mapping || !mapping.enabled) {
return null
}
const gateway = config.gateways[mapping.gateway]
if (!gateway) {
return null
}
// Validate based on gateway type
if (gateway.type === "command") {
if (!gateway.command) return null
} else {
// HTTP gateway
if (!gateway.url) return null
}
return { gatewayName: mapping.gateway, gateway, instruction: mapping.instruction }
}
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
}
}