293 lines
12 KiB
TypeScript
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);
|