/** * pi-gateway Programmatic API * * Allows starting/stopping the gateway from outside a pi session. * Used by src/index.ts to auto-start the gateway at boot. * * When pi-gateway is also loaded as a pi extension (via pi-kobold), * the extension factory attaches to the already-running instance. */ import { homedir } from "node:os"; import { join } from "node:path"; import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { WebSocketServer, WebSocket } from "ws"; import { randomBytes } from "node:crypto"; import { initSessionStore, getOrCreateSession, listSessions, touchSession, type SessionConfig, } from "./sessions/store.js"; import { initSecurityStore, isUserAllowed, approvePairingCode, generatePairingCode, listPendingPairingCodes, addToAllowlist, listAllowlistedUsers, type Platform, } from "./security/auth.js"; import { initBackgroundTasks, startBackgroundTask, getPendingResultsForSession, markTaskDelivered, listTasks, type BackgroundTask, } from "./background/manager.js"; import { DiscordAdapter } from "./adapters/discord.js"; import { TwitchAdapter } from "./adapters/twitch.js"; import { TelegramAdapter } from "./adapters/telegram.js"; import { SlackAdapter } from "./adapters/slack.js"; import { WhatsAppAdapter } from "./adapters/whatsapp.js"; import { BaseAdapter, type AdapterCallbacks, type PlatformMessage } from "./adapters/base.js"; const KOBOLD_DIR = join(homedir(), ".0xkobold"); const CONFIG_DIR = join(KOBOLD_DIR, "gateway"); const CONFIG_FILE = join(CONFIG_DIR, "config.json"); // ═════════════════════════════════════════════════════════════════════════════ // Types // ═════════════════════════════════════════════════════════════════════════════ export interface GatewayConfig { port: number; host: string; tokens: string[]; corsOrigins: string[]; enableWebSocket: boolean; enableHttp: boolean; security: { allowAll: boolean; requirePairing: boolean; }; sessions: { resetPolicy: "daily" | "idle" | "both"; dailyHour: number; idleMinutes: number; }; platforms: { discord?: { enabled: boolean; botToken: string; guildId?: string }; twitch?: { enabled: boolean; clientId: string; clientSecret: string; channels?: string[] }; telegram?: { enabled: boolean; token: string; mode?: "polling" | "webhook"; webhookUrl?: string }; slack?: { enabled: boolean; webhookUrl?: string; botToken?: string }; whatsapp?: { enabled: boolean; sessionPath?: string; printQr?: boolean }; }; } export interface GatewayStatus { running: boolean; port: number; host: string; adapters: string[]; clientCount: number; sessionCount: number; agentConnected: boolean; } export interface StartGatewayOptions { port?: number; host?: string; /** Don't spawn a pi RPC process (use when pi is already running) */ noAgent?: boolean; } // ═════════════════════════════════════════════════════════════════════════════ // Module-level state (shared with extension factory) // ═════════════════════════════════════════════════════════════════════════════ const DEFAULT_CONFIG: GatewayConfig = { port: 3847, host: "localhost", tokens: [], corsOrigins: ["*"], enableWebSocket: true, enableHttp: true, security: { allowAll: true, requirePairing: false }, sessions: { resetPolicy: "idle", dailyHour: 4, idleMinutes: 1440 }, platforms: {}, }; let config: GatewayConfig = { ...DEFAULT_CONFIG }; let running = false; let adapters = new Map(); let clients = new Map(); let sessions = new Map(); let server: ReturnType | null = null; let wss: WebSocketServer | null = null; let rpcProcess: any = null; let cronInterval: ReturnType | null = null; let storesInitialized = false; interface PendingRequest { id: string; resolve: (msg: unknown) => void; reject: (err: Error) => void; } const pendingRequests: PendingRequest[] = []; // ═════════════════════════════════════════════════════════════════════════════ // Internal helpers // ═════════════════════════════════════════════════════════════════════════════ function loadConfig(): GatewayConfig { try { if (existsSync(CONFIG_FILE)) { return { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_FILE, "utf-8")) }; } } catch { /* ignore */ } return { ...DEFAULT_CONFIG }; } function saveConfig(): void { try { if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); } catch { /* ignore */ } } function verifyToken(token: string): boolean { if (config.tokens.length === 0) return true; return config.tokens.includes(token); } function authenticate(req: IncomingMessage): boolean { const auth = req.headers.authorization; if (!auth) return verifyToken(""); if (auth.startsWith("Bearer ")) return verifyToken(auth.slice(7)); return false; } function sendWs(ws: WebSocket, msg: object): void { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(msg)); } } function broadcastClients(event: string, data: unknown): void { for (const ws of clients.values()) { sendWs(ws, { type: event, data }); } } function createRpcProcess(): any { const { spawn } = require("node:child_process"); const proc = spawn("pi", ["--mode", "rpc", "--json"], { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, OLLAMA_HOST: process.env.OLLAMA_HOST || "localhost:11434" }, }); proc.stdout?.on("data", (data: Buffer) => { for (const line of data.toString().split("\n").filter(Boolean)) { try { const msg = JSON.parse(line); if (msg.id) { const idx = pendingRequests.findIndex(r => r.id === msg.id); if (idx !== -1) { pendingRequests.splice(idx, 1)[0].resolve(msg); } } if (msg.type === "response") broadcastClients("response", msg); else broadcastClients("event", msg); } catch { /* not JSON */ } } }); proc.stderr?.on("data", (data: Buffer) => { console.error("[gateway] pi stderr:", data.toString()); }); proc.on("exit", (code: number) => { console.log("[gateway] pi process exited"); rpcProcess = null; broadcastClients("agent_disconnected", { code }); }); return proc; } async function sendRpc(command: string, data: Record = {}): Promise { if (!rpcProcess) throw new Error("pi agent not running"); const id = randomBytes(8).toString("hex"); const payload = { id, type: command, ...data }; return new Promise((resolve, reject) => { pendingRequests.push({ id, resolve, reject }); try { rpcProcess.stdin.write(JSON.stringify(payload) + "\n"); } catch (err) { const idx = pendingRequests.findIndex(r => r.id === id); if (idx !== -1) pendingRequests.splice(idx, 1); reject(err); } setTimeout(() => { const idx = pendingRequests.findIndex(r => r.id === id); if (idx !== -1) { pendingRequests.splice(idx, 1); reject(new Error("Request timeout")); } }, 30000); }); } const adapterCallbacks: AdapterCallbacks = { onMessage: async (message: PlatformMessage) => { const session = await getOrCreateSession( message.platform, message.channelId, message.userId, { resetPolicy: config.sessions.resetPolicy, dailyHour: config.sessions.dailyHour, idleMinutes: config.sessions.idleMinutes, } ); if (!(await isUserAllowed(message.platform as Platform, message.userId))) { console.log(`[gateway] User ${message.userId} not in allowlist`); return; } sessions.set(`${message.platform}:${message.channelId}`, session); if (rpcProcess) { await sendRpc("prompt", { message: message.content, sessionId: session.id }); } }, onDisconnect: () => { console.log("[gateway] Platform adapter disconnected"); }, }; async function initializeAdapters(): Promise { if (config.platforms.discord?.enabled && config.platforms.discord.botToken) { try { const adapter = new DiscordAdapter({ enabled: true, platform: "discord", botToken: config.platforms.discord.botToken, guildId: config.platforms.discord.guildId, }); await adapter.initialize(); await adapter.start(adapterCallbacks); adapters.set("discord", adapter); console.log("[gateway] Discord adapter started"); } catch (err) { console.error("[gateway] Failed to start Discord adapter:", err); } } if (config.platforms.twitch?.enabled && config.platforms.twitch.clientId && config.platforms.twitch.clientSecret) { try { const adapter = new TwitchAdapter({ enabled: true, platform: "twitch", clientId: config.platforms.twitch.clientId, clientSecret: config.platforms.twitch.clientSecret, channels: config.platforms.twitch.channels, }); await adapter.initialize(); await adapter.start(adapterCallbacks); adapters.set("twitch", adapter); console.log("[gateway] Twitch adapter started"); } catch (err) { console.error("[gateway] Failed to start Twitch adapter:", err); } } if (config.platforms.telegram?.enabled && config.platforms.telegram.token) { try { const adapter = new TelegramAdapter({ enabled: true, platform: "telegram", token: config.platforms.telegram.token, mode: config.platforms.telegram.mode, webhookUrl: config.platforms.telegram.webhookUrl, }); await adapter.initialize(); await adapter.start(adapterCallbacks); adapters.set("telegram", adapter); console.log("[gateway] Telegram adapter started"); } catch (err) { console.error("[gateway] Failed to start Telegram adapter:", err); } } if (config.platforms.slack?.enabled && (config.platforms.slack.webhookUrl || config.platforms.slack.botToken)) { try { const adapter = new SlackAdapter({ enabled: true, platform: "slack", webhookUrl: config.platforms.slack.webhookUrl, botToken: config.platforms.slack.botToken, }); await adapter.initialize(); await adapter.start(adapterCallbacks); adapters.set("slack", adapter); console.log("[gateway] Slack adapter started"); } catch (err) { console.error("[gateway] Failed to start Slack adapter:", err); } } if (config.platforms.whatsapp?.enabled) { try { const adapter = new WhatsAppAdapter({ enabled: true, platform: "whatsapp", sessionPath: config.platforms.whatsapp.sessionPath, printQr: config.platforms.whatsapp.printQr, }); await adapter.initialize(); await adapter.start(adapterCallbacks); adapters.set("whatsapp", adapter); console.log("[gateway] WhatsApp adapter started"); } catch (err) { console.error("[gateway] Failed to start WhatsApp adapter:", err); } } } function startCron(): void { cronInterval = setInterval(async () => { for (const session of sessions.values()) { const pending = await getPendingResultsForSession(session.id); for (const task of pending) { const adapter = adapters.get(session.platform); if (adapter) { const resultText = task.status === "completed" ? `✅ Background task completed:\n\`\`\`\n${JSON.stringify(task.result, null, 2)}\n\`\`\`` : `❌ Background task failed:\n\`\`\`\n${task.error}\n\`\`\``; await adapter.sendMessage(session.channelId, resultText); await markTaskDelivered(task.id); } } } for (const session of sessions.values()) { await touchSession(session.id); } }, 60000); } function stopCron(): void { if (cronInterval) { clearInterval(cronInterval); cronInterval = null; } } async function handleHttpRequest(req: IncomingMessage, res: ServerResponse): Promise { res.setHeader("Access-Control-Allow-Origin", config.corsOrigins.join(",") || "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } if (!authenticate(req)) { res.writeHead(401); res.end(JSON.stringify({ error: "Unauthorized" })); return; } const url = new URL(req.url || "/", `http://${req.headers.host}`); if (url.pathname === "/api/status" && req.method === "GET") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ running, adapters: Array.from(adapters.keys()), clients: clients.size, sessions: sessions.size, agent: rpcProcess !== null, })); return; } if (url.pathname === "/api/sessions" && req.method === "GET") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(await listSessions())); return; } if (url.pathname === "/api/background" && req.method === "GET") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(await listTasks())); return; } if (url.pathname === "/api/allowlist" && req.method === "GET") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(await listAllowlistedUsers())); return; } if (url.pathname === "/api/pairing" && req.method === "GET") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(await listPendingPairingCodes())); return; } res.writeHead(404); res.end(JSON.stringify({ error: "Not found" })); } function handleWebSocket(ws: WebSocket, req: IncomingMessage): void { if (!authenticate(req)) { ws.close(1008, "Unauthorized"); return; } const clientId = randomBytes(8).toString("hex"); clients.set(clientId, ws); console.log(`[gateway] WebSocket client connected: ${clientId}`); sendWs(ws, { type: "connected", data: { clientId } }); ws.on("message", async (data) => { try { const msg = JSON.parse(data.toString()); switch (msg.type) { case "prompt": { const result = await sendRpc("prompt", { message: msg.data?.message || "" }); sendWs(ws, { type: "response", id: msg.id, data: result }); break; } case "background": { const task = await startBackgroundTask(msg.data?.sessionId || "default", msg.data?.command || ""); sendWs(ws, { type: "background_started", data: task }); break; } case "ping": { sendWs(ws, { type: "pong", data: { time: Date.now() } }); break; } } } catch (err) { sendWs(ws, { type: "error", data: { error: String(err) } }); } }); ws.on("close", () => { clients.delete(clientId); console.log(`[gateway] WebSocket client disconnected: ${clientId}`); }); } // ═════════════════════════════════════════════════════════════════════════════ // Public API // ═════════════════════════════════════════════════════════════════════════════ /** * Start the gateway server programmatically. * * Call this from src/index.ts or any boot code. * When pi-gateway also loads as a pi extension, it will detect * the already-running instance and attach to it. */ export async function startGateway(opts: StartGatewayOptions = {}): Promise { if (running) { return getStatus(); } config = loadConfig(); if (opts.port) config.port = opts.port; if (opts.host) config.host = opts.host; // Initialize stores (idempotent) if (!storesInitialized) { await Promise.all([initSessionStore(), initSecurityStore(), initBackgroundTasks()]); storesInitialized = true; } // Start HTTP server server = createServer(handleHttpRequest); if (config.enableWebSocket) { wss = new WebSocketServer({ server }); wss.on("connection", handleWebSocket); } await new Promise((resolve, reject) => { server!.listen(config.port, config.host, () => resolve()); server!.on("error", reject); }); console.log(`[gateway] HTTP server started on ${config.host}:${config.port}`); // Start pi agent (unless disabled — e.g. when pi is already running) if (!opts.noAgent) { rpcProcess = createRpcProcess(); } // Initialize platform adapters await initializeAdapters(); // Start cron startCron(); running = true; console.log( `[gateway] Started — platforms: ${adapters.size > 0 ? Array.from(adapters.keys()).join(", ") : "none"}, ` + `sessions: idle reset every ${config.sessions.idleMinutes} min` ); return getStatus(); } /** * Stop the gateway server. */ export async function stopGateway(): Promise { if (!running) return; // Stop adapters for (const adapter of adapters.values()) { await adapter.stop(); } adapters.clear(); // Stop cron stopCron(); // Close WebSocket clients for (const ws of clients.values()) { ws.close(1000, "Server shutting down"); } clients.clear(); sessions.clear(); // Stop HTTP server server?.close(); server = null; wss = null; // Kill pi process if (rpcProcess) { rpcProcess.kill(); rpcProcess = null; } running = false; console.log("[gateway] Stopped"); } /** * Check if the gateway is already running on a given port. * Useful for detecting an existing instance at boot. */ export async function isGatewayRunning(port: number = 3847): Promise { try { const response = await fetch(`http://localhost:${port}/api/status`); const data = (await response.json()) as Record; return data.running === true; } catch { return false; } } /** * Get the current gateway status. */ export function getStatus(): GatewayStatus { return { running, port: config.port, host: config.host, adapters: Array.from(adapters.keys()), clientCount: clients.size, sessionCount: sessions.size, agentConnected: rpcProcess !== null, }; } /** * Get the current gateway config. */ export function getConfig(): GatewayConfig { return config; } /** * Check if the gateway is running. */ export function isRunning(): boolean { return running; } /** * Get the adapter for a platform, if running. */ export function getAdapter(platform: string): BaseAdapter | undefined { return adapters.get(platform); } /** * Get all active adapters. */ export function getAdapters(): Map { return adapters; } /** * Send a message through a platform adapter. */ export async function sendMessage(platform: string, channelId: string, content: string): Promise { const adapter = adapters.get(platform); if (!adapter) return false; await adapter.sendMessage(channelId, content); return true; } /** * Broadcast a message to all connected WebSocket clients. */ export function broadcast(event: string, data: unknown): void { broadcastClients(event, data); }