it0/packages/openclaw-bridge/src/index.ts

293 lines
12 KiB
TypeScript

/**
* IT0 Bridge — runs alongside the OpenClaw gateway inside the container.
* Exposes a REST API on port 3000 that:
* - Forwards task submissions to OpenClaw via WebSocket RPC
* - Returns status, session list, and metrics
* - Reports heartbeat to IT0 agent-service
* - Injects SKILL.md files into the container volume (POST /skill-inject)
*
* Skill injection flow:
* iAgent (agent-service) receives user request → calls POST /api/v1/agent/instances/:id/skills
* → agent-service forwards to this bridge's POST /skill-inject
* → bridge writes SKILL.md to ~/.openclaw/skills/{name}/
* → OpenClaw gateway file watcher picks it up within 250ms
* → bridge deletes the current OpenClaw session (sessions.delete RPC) so the
* next user message opens a fresh session that loads the new skill directory
*
* Skill directory layout (inside the container volume):
* $OPENCLAW_HOME/skills/
* └── {skill-slug}/
* └── SKILL.md ← YAML frontmatter (name, description) + markdown body
*/
import 'dotenv/config';
import express from 'express';
import * as crypto from 'crypto';
import * as fs from 'fs/promises';
import * as path from 'path';
import { OpenClawClient } from './openclaw-client';
// Skills live in the mounted OpenClaw home dir
const OPENCLAW_HOME = process.env.OPENCLAW_HOME ?? '/home/node/.openclaw';
const SKILLS_DIR = path.join(OPENCLAW_HOME, 'skills');
const PORT = parseInt(process.env.BRIDGE_PORT ?? '3000', 10);
const OPENCLAW_GATEWAY = process.env.OPENCLAW_GATEWAY_URL ?? 'ws://127.0.0.1:18789';
const OPENCLAW_TOKEN = process.env.OPENCLAW_GATEWAY_TOKEN ?? '';
const INSTANCE_ID = process.env.IT0_INSTANCE_ID ?? 'unknown';
const IT0_AGENT_URL = process.env.IT0_AGENT_SERVICE_URL ?? '';
const app = express();
app.use(express.json());
// ── OpenClaw client ──────────────────────────────────────────────────────────
const ocClient = new OpenClawClient(OPENCLAW_GATEWAY, OPENCLAW_TOKEN);
let startTime = Date.now();
let gatewayReady = false;
async function connectWithRetry(maxRetries = 20, delayMs = 3000): Promise<void> {
for (let i = 0; i < maxRetries; i++) {
try {
await ocClient.connect();
gatewayReady = true;
console.log('[bridge] Connected to OpenClaw gateway');
return;
} catch (err) {
console.warn(`[bridge] Gateway not ready (attempt ${i + 1}/${maxRetries}), retrying...`);
await new Promise((r) => setTimeout(r, delayMs));
}
}
throw new Error('Could not connect to OpenClaw gateway after retries');
}
// ── Routes ───────────────────────────────────────────────────────────────────
// Health check — used by IT0 monitoring
app.get('/health', (_req, res) => {
res.json({
status: gatewayReady ? 'ok' : 'starting',
instanceId: INSTANCE_ID,
uptime: Math.floor((Date.now() - startTime) / 1000),
gatewayConnected: ocClient.isConnected(),
});
});
// Status — detailed instance info
app.get('/status', (_req, res) => {
res.json({
instanceId: INSTANCE_ID,
gatewayConnected: ocClient.isConnected(),
uptime: Math.floor((Date.now() - startTime) / 1000),
});
});
// Submit a task to OpenClaw and wait for the agent's final reply.
//
// OpenClaw Protocol v3 flow:
// 1. POST /task → bridge calls chat.send → gets { runId, status:"started" } ack
// 2. Agent processes, pushes WS events (type:"event", event:"chat")
// 3. Final event { state:"final", message:{content:[{type:"text",text:"..."}]} }
// is captured by the bridge's event listener and returned as the HTTP response.
//
// Request body:
// sessionKey — OpenClaw session key, e.g. "agent:main:dt-<userId>"
// prompt — message text to send
// idempotencyKey — caller-supplied unique key (DingTalk msgId etc.) for dedup
// timeoutSeconds — max seconds to wait for agent reply (default 25)
app.post('/task', async (req, res) => {
if (!ocClient.isConnected()) {
res.status(503).json({ error: 'Gateway not connected' });
return;
}
try {
const sessionKey = req.body.sessionKey ?? `agent:main:${req.body.sessionId ?? 'main'}`;
const timeoutSeconds: number = req.body.timeoutSeconds ?? 25;
// idempotencyKey is mandatory in chat.send (Protocol v3). Use caller-supplied value
// (so DingTalk msgId is preserved for dedup), or generate a UUID for ad-hoc calls.
const idempotencyKey: string = req.body.idempotencyKey ?? crypto.randomUUID();
const reply = await ocClient.chatSendAndWait({
sessionKey,
message: req.body.prompt,
idempotencyKey,
timeoutMs: timeoutSeconds * 1000,
});
res.json({ ok: true, result: reply });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Submit a task asynchronously — returns immediately, POSTs result to callbackUrl when done.
//
// Request body (same as /task, plus):
// callbackUrl — URL to POST the result to when the agent replies
// callbackData — opaque object forwarded unchanged in the callback body
//
// Response: { ok: true, pending: true }
// Callback body (POST to callbackUrl):
// { ok: true, result: string, callbackData } — on success
// { ok: false, error: string, callbackData } — on LLM/timeout error
// { ok: false, error: "callback_url_missing", ... } — config error (logged only)
app.post('/task-async', async (req, res) => {
const callbackUrl: string | undefined = req.body.callbackUrl;
if (!callbackUrl) {
res.status(400).json({ error: 'callbackUrl is required' });
return;
}
if (!ocClient.isConnected()) {
res.status(503).json({ error: 'Gateway not connected' });
return;
}
const sessionKey = req.body.sessionKey ?? `agent:main:${req.body.sessionId ?? 'main'}`;
const timeoutSeconds: number = req.body.timeoutSeconds ?? 120; // 2 min default for async tasks
const idempotencyKey: string = req.body.idempotencyKey ?? crypto.randomUUID();
const callbackData = req.body.callbackData ?? {};
// Optional attachments — passed through to OpenClaw chat.send unchanged.
// Expected format: [{ name, mimeType, media }] where media is a data-URI.
const attachments: Array<{ name: string; mimeType: string; media: string }> | undefined =
Array.isArray(req.body.attachments) && req.body.attachments.length > 0
? req.body.attachments
: undefined;
// Return immediately — LLM runs in background
res.json({ ok: true, pending: true });
const postCallback = (body: object) => {
fetch(callbackUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).catch((e: Error) => console.error('[bridge] callback POST failed:', e.message));
};
ocClient.chatSendAndWait({
sessionKey,
message: req.body.prompt,
idempotencyKey,
timeoutMs: timeoutSeconds * 1000,
attachments,
}).then((reply: string) => {
postCallback({ ok: true, result: reply, callbackData });
}).catch((err: Error) => {
const isTimeout = err.message.toLowerCase().includes('timeout');
postCallback({ ok: false, error: err.message, isTimeout, callbackData });
});
});
// Inject a skill into this OpenClaw instance.
//
// Writes SKILL.md to ~/.openclaw/skills/{name}/ so the gateway's file watcher
// picks it up within 250ms. Then optionally deletes the caller's current session
// so the next message starts a fresh session — new sessions pick up newly added
// skill directories immediately, giving the user a seamless experience.
//
// Request body:
// name — skill directory name (slug, e.g. "it0-weather")
// content — full SKILL.md content (markdown with YAML frontmatter)
// sessionKey — (optional) current session key to delete after injection
//
// Response: { ok, injected, sessionCleared }
app.post('/skill-inject', async (req, res) => {
const { name, content, sessionKey } = req.body ?? {};
if (!name || typeof name !== 'string' || !content || typeof content !== 'string') {
res.status(400).json({ error: 'name and content are required strings' });
return;
}
// Prevent path traversal
if (name.includes('/') || name.includes('..') || name.includes('\\')) {
res.status(400).json({ error: 'invalid skill name' });
return;
}
const skillDir = path.join(SKILLS_DIR, name);
try {
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf8');
console.log(`[bridge] Skill injected: ${name}${skillDir}`);
} catch (err: any) {
console.error(`[bridge] Failed to write skill ${name}:`, err.message);
res.status(500).json({ error: `Failed to write skill: ${err.message}` });
return;
}
// Delete the caller's current session so the next message opens a fresh session
// that picks up the newly added skill directory.
let sessionCleared = false;
if (sessionKey && typeof sessionKey === 'string' && ocClient.isConnected()) {
try {
await ocClient.rpc('sessions.delete', { sessionKey }, 5_000);
sessionCleared = true;
console.log(`[bridge] Session cleared after skill inject: ${sessionKey}`);
} catch (err: any) {
// Non-fatal — skill is injected, session will naturally expire or be replaced
console.warn(`[bridge] Could not delete session ${sessionKey}: ${err.message}`);
}
}
res.json({ ok: true, injected: true, sessionCleared });
});
// List sessions
app.get('/sessions', async (_req, res) => {
if (!ocClient.isConnected()) {
res.status(503).json({ error: 'Gateway not connected' });
return;
}
try {
const result = await ocClient.rpc('sessions.list', { limit: 100 });
res.json(result);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Gateway status (metrics.get does not exist in OpenClaw Protocol v3)
app.get('/metrics', async (_req, res) => {
if (!ocClient.isConnected()) {
res.status(503).json({ error: 'Gateway not connected' });
return;
}
try {
const result = await ocClient.rpc('gateway.status');
res.json(result);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ── Heartbeat to IT0 agent-service ───────────────────────────────────────────
async function sendHeartbeat(): Promise<void> {
if (!IT0_AGENT_URL || !INSTANCE_ID || INSTANCE_ID === 'unknown') return;
try {
await fetch(`${IT0_AGENT_URL}/api/v1/agent/instances/${INSTANCE_ID}/heartbeat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
gatewayConnected: ocClient.isConnected(),
uptime: Math.floor((Date.now() - startTime) / 1000),
}),
});
} catch {
// Best-effort, don't crash on network error
}
}
// ── Start ─────────────────────────────────────────────────────────────────────
app.listen(PORT, () => {
console.log(`[bridge] IT0 Bridge listening on port ${PORT}`);
startTime = Date.now();
});
connectWithRetry().catch((err) => {
console.error('[bridge] Fatal: could not connect to OpenClaw gateway:', err.message);
// Keep bridge running for health-check endpoint even if gateway failed
});
// Heartbeat every 60 seconds
setInterval(sendHeartbeat, 60_000);