From e00c6db5447f82e218c35b7a060b7a692b69dd9d Mon Sep 17 00:00:00 2001 From: Manohar Date: Fri, 5 Jun 2026 19:49:23 +0000 Subject: [PATCH] feat(bridge): add alerts, context, spawn, suggestions, health, feedback, knowledge, chat-mirror, telegram-webhook, angel/positions routes + wire in index.ts --- bridge/src/index.ts | 15 +++ bridge/src/routes/alerts.ts | 88 ++++++++++++++++ bridge/src/routes/angel/positions.ts | 138 ++++++++++++++++++++++++++ bridge/src/routes/chat-mirror.ts | 68 +++++++++++++ bridge/src/routes/context.ts | 83 ++++++++++++++++ bridge/src/routes/feedback.ts | 78 +++++++++++++++ bridge/src/routes/health.ts | 75 ++++++++++++++ bridge/src/routes/knowledge.ts | 126 +++++++++++++++++++++++ bridge/src/routes/spawn.ts | 66 ++++++++++++ bridge/src/routes/suggestions.ts | 66 ++++++++++++ bridge/src/routes/telegram-webhook.ts | 74 ++++++++++++++ 11 files changed, 877 insertions(+) create mode 100644 bridge/src/routes/alerts.ts create mode 100644 bridge/src/routes/angel/positions.ts create mode 100644 bridge/src/routes/chat-mirror.ts create mode 100644 bridge/src/routes/context.ts create mode 100644 bridge/src/routes/feedback.ts create mode 100644 bridge/src/routes/health.ts create mode 100644 bridge/src/routes/knowledge.ts create mode 100644 bridge/src/routes/spawn.ts create mode 100644 bridge/src/routes/suggestions.ts create mode 100644 bridge/src/routes/telegram-webhook.ts diff --git a/bridge/src/index.ts b/bridge/src/index.ts index 1405ec4..a103946 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -63,6 +63,11 @@ import express from "express"; import cors from "cors"; import { authMiddleware } from "./auth.js"; import statusRouter from "./routes/status.js"; +import healthRouter from "./routes/health.js"; +import suggestionsRouter from "./routes/suggestions.js"; +import alertsRouter from "./routes/alerts.js"; +import spawnRouter from "./routes/spawn.js"; +import contextRouter from "./routes/context.js"; import logsRouter from "./routes/logs.js"; import execRouter from "./routes/exec.js"; import configRouter from "./routes/config.js"; @@ -120,6 +125,13 @@ app.get("/health", (_req, res) => { // Tiger endpoints — all scoped under /tiger app.use("/tiger/status", statusRouter); +app.use("/tiger/health", healthRouter); +app.use("/tiger/suggestions", suggestionsRouter); +app.use("/tiger/alerts", alertsRouter); +app.use("/tiger/spawn", spawnRouter); +app.use("/tiger/context", contextRouter); +app.use("/tiger/knowledge", (await import("./routes/knowledge.js")).default); +app.use("/tiger/feedback", (await import("./routes/feedback.js")).default); app.use("/tiger/logs", logsRouter); // SSE stream app.use("/tiger/exec", execRouter); app.use("/tiger/config", configRouter); @@ -141,6 +153,9 @@ app.use("/tiger/deploy-dashboard", deployRouter); app.use("/tiger/route-task", routeTaskRouter); app.use("/tiger/keys", keysRouter); app.use("/tiger/chat", (await import("./routes/chat.js")).default); +app.use("/tiger/chat/mirror", (await import("./routes/chat-mirror.js")).default); +app.use("/tiger/telegram-webhook", (await import("./routes/telegram-webhook.js")).default); +app.use("/angel", (await import("./routes/angel/positions.js")).default); // Gateway proxy — forwards to gateway inside Tiger container // This is needed because the dashboard runs in Dokploy which can't reach the container directly diff --git a/bridge/src/routes/alerts.ts b/bridge/src/routes/alerts.ts new file mode 100644 index 0000000..263cafc --- /dev/null +++ b/bridge/src/routes/alerts.ts @@ -0,0 +1,88 @@ +/** + * alerts.ts — GET /tiger/alerts + * + * Returns active alerts and allows configuring proactive notifications. + * + * GET /tiger/alerts — get active alerts + * GET /tiger/alerts?check=true — run check and return fresh alerts + * + * Response: + * { ok: true, alerts: [{ type, message, priority, timestamp }] } + */ + +import { Router, Request, Response } from "express"; +import { execInSandbox } from "../tiger.js"; + +const router = Router(); + +interface Alert { + type: string + message: string + priority: "high" | "medium" | "low" + timestamp: string +} + +async function checkSystemAlerts(): Promise { + const alerts: Alert[] = [] + const now = new Date().toISOString() + + try { + // Check memory usage + const memResult = await execInSandbox("cat /proc/meminfo | grep MemAvailable") + if (memResult.stdout.includes("MemAvailable")) { + const match = memResult.stdout.match(/(\d+)/) + if (match) { + const availableMB = parseInt(match[1]) / 1024 + if (availableMB < 500) { + alerts.push({ + type: "memory", + message: `Low memory: ${Math.round(availableMB)}MB available`, + priority: "high", + timestamp: now + }) + } + } + } + + // Check disk usage + const diskResult = await execInSandbox("df -h / | tail -1") + if (diskResult.stdout.includes("%")) { + const match = diskResult.stdout.match(/(\d+)%/) + if (match) { + const usage = parseInt(match[1]) + if (usage > 85) { + alerts.push({ + type: "disk", + message: `High disk usage: ${usage}%`, + priority: usage > 95 ? "high" : "medium", + timestamp: now + }) + } + } + } + + } catch (e) { + // System check failed + } + + return alerts +} + +router.get("/", async (req: Request, res: Response) => { + const doCheck = req.query.check === "true" + + let alerts: Alert[] = [] + + if (doCheck) { + alerts = await checkSystemAlerts() + } + + res.json({ + ok: true, + alerts, + lastCheck: new Date().toISOString(), + count: alerts.length + }) +}) + +export default router \ No newline at end of file diff --git a/bridge/src/routes/angel/positions.ts b/bridge/src/routes/angel/positions.ts new file mode 100644 index 0000000..d7e2f14 --- /dev/null +++ b/bridge/src/routes/angel/positions.ts @@ -0,0 +1,138 @@ +/** + * angel-positions.ts — Fetch open positions from Angel ONE Smart API + * + * Endpoints: + * - POST /login — Get JWT token + * - GET /portfolio — Fetch positions + */ + +import { Router, Request, Response } from "express"; +import axios from "axios"; +import * as OTPAuth from "otplib"; + +const router = Router(); + +const API_BASE = process.env.DATA_SOURCE === "live" + ? "https://smartapi.angelone.in" + : "https://smapi.angelone.in"; + +const API_KEY = process.env.ANGEL_ONE_API_KEY || ""; +const CLIENT_ID = process.env.ANGEL_ONE_CLIENT_ID || ""; +const PASSWORD = process.env.ANGEL_ONE_PASSWORD || ""; +const TOTP_SECRET = process.env.ANGEL_ONE_TOTP_SECRET || ""; + +// In-memory token cache (simple, valid for ~1 day) +let cachedToken: string | null = null; +let tokenExpiry: number = 0; + +// Generate TOTP from secret +function generateTOTP(secret: string): string { + try { + const totp = new OTPAuth.TOTP({ + issuer: "AngelOne", + label: "SmartAPI", + algorithm: "sha1", + digits: 6, + period: 30, + secret: secret + }); + return totp.generate() as string; + } catch (e) { + console.error("[angel] TOTP generation failed:", e); + return ""; + } +} + +// Login to Angel ONE and get JWT token +async function getToken(): Promise { + // Check cache + if (cachedToken && Date.now() < tokenExpiry) { + return cachedToken; + } + + try { + const totp = generateTOTP(TOTP_SECRET); + if (!totp) { + throw new Error("TOTP generation failed"); + } + + const payload = { + clientcode: CLIENT_ID, + password: PASSWORD, + totp: totp, + apiKey: API_KEY + }; + + const response = await axios.post(`${API_BASE}/login`, payload, { + headers: { "Content-Type": "application/json" } + }); + + if (response.data?.status?.success) { + cachedToken = response.data.data.jwtToken; + // Token valid for ~24 hours, cache for 23 hours + tokenExpiry = Date.now() + (23 * 60 * 60 * 1000); + return cachedToken; + } else { + console.error("[angel] Login failed:", response.data); + return null; + } + } catch (err: any) { + console.error("[angel] Login error:", err.message); + return null; + } +} + +// Fetch open positions +async function getPositions(): Promise { + const token = await getToken(); + if (!token) { + throw new Error("Failed to get Angel ONE token"); + } + + try { + const response = await axios.get(`${API_BASE}/portfolio`, { + headers: { + "Authorization": token, + "Content-Type": "application/json" + } + }); + + if (response.data?.status?.success) { + return response.data.data || []; + } else { + return []; + } + } catch (err: any) { + console.error("[angel] Portfolio fetch error:", err.message); + return []; + } +} + +// GET /angel/positions — Get current positions +router.get("/positions", async (req: Request, res: Response) => { + try { + const positions = await getPositions(); + res.json({ ok: true, count: positions.length, positions }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +// GET /angel/positions/short — Short summary (symbol + P&L only) +router.get("/positions/short", async (req: Request, res: Response) => { + try { + const positions = await getPositions(); + const summary = positions.map((p: any) => ({ + symbol: p.tradingSymbol || p.symbol || p.tsym, + pnl: p.pnl || p.realizedPnL || p.unrealizedPnL || 0, + quantity: p.quantity || p.qty || 0, + ltp: p.ltp || p.avgPrice || 0 + })); + res.json({ ok: true, summary }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +export default router; +export { getPositions, getToken }; \ No newline at end of file diff --git a/bridge/src/routes/chat-mirror.ts b/bridge/src/routes/chat-mirror.ts new file mode 100644 index 0000000..47f874a --- /dev/null +++ b/bridge/src/routes/chat-mirror.ts @@ -0,0 +1,68 @@ +/** + * chat-mirror.ts — Mirror Telegram messages to shared SQLite + * + * This is a simple endpoint that can be called to mirror messages + * from any channel (Telegram, WhatsApp, etc.) into the chat history. + * + * POST /tiger/chat/mirror + * Body: { + * role: "user" | "agent", + * content: "message text", + * source: "telegram" | "whatsapp" | "web", + * sessionId?: "agent:main:main" + * } + * + * Response: { ok: true, id: number } + */ + +import { Router, Request, Response } from "express"; +import db from "../db.js"; + +const router = Router(); + +const DEFAULT_SESSION_ID = "agent:main:main"; + +const insertMessage = db.prepare(` + INSERT INTO chat_messages (session_id, role, content, meta) + VALUES (?, ?, ?, ?) +`); + +// POST /tiger/chat/mirror — store a message from any source +router.post("/", async (req: Request, res: Response) => { + const { role, content, source, sessionId } = req.body; + + if (!role || !content) { + return res.status(400).json({ + ok: false, + error: "role and content are required" + }); + } + + if (role !== "user" && role !== "agent" && role !== "system") { + return res.status(400).json({ + ok: false, + error: "role must be 'user', 'agent', or 'system'" + }); + } + + const sid = sessionId || DEFAULT_SESSION_ID; + const meta = JSON.stringify({ + source: source || "unknown", + mirrored: true, + timestamp: new Date().toISOString() + }); + + try { + const info = insertMessage.run(sid, role, content, meta); + res.json({ + ok: true, + id: info.lastInsertRowid, + sessionId: sid, + source: source || "unknown" + }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +export default router; \ No newline at end of file diff --git a/bridge/src/routes/context.ts b/bridge/src/routes/context.ts new file mode 100644 index 0000000..8011d14 --- /dev/null +++ b/bridge/src/routes/context.ts @@ -0,0 +1,83 @@ +/** + * context.ts — GET/POST /tiger/context + * + * Session context storage - remembers context across messages. + * This enables T012: Context Injection. + * + * GET /tiger/context?sessionId=X — get context for session + * POST /tiger/context — set context { sessionId, key, value } + * DELETE /tiger/context?sessionId=X — clear context + * + * Response: + * { ok: true, context: { ... }, message } + */ + +import { Router, Request, Response } from "express"; +import db from "../db.js"; + +const router = Router(); + +// Table for session context +const getContext = db.prepare(` + SELECT key, value FROM session_context WHERE session_id = ? +`); +const setContext = db.prepare(` + INSERT OR REPLACE INTO session_context (session_id, key, value, updated_at) + VALUES (?, ?, ?, datetime('now')) +`); +const clearContext = db.prepare(` + DELETE FROM session_context WHERE session_id = ? +`); + +const DEFAULT_SESSION = "agent:main:main"; + +// GET context +router.get("/", async (req: Request, res: Response) => { + const sessionId = (req.query.sessionId as string) || DEFAULT_SESSION; + + try { + const rows = getContext.all(sessionId) as any[]; + const context: Record = {}; + for (const row of rows) { + context[row.key] = row.value; + } + + res.json({ ok: true, sessionId, context }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +// POST set context +router.post("/", async (req: Request, res: Response) => { + const { sessionId, key, value } = req.body; + const sid = sessionId || DEFAULT_SESSION; + + if (!key) { + return res.status(400).json({ ok: false, error: "key is required" }); + } + if (!value) { + return res.status(400).json({ ok: false, error: "value is required" }); + } + + try { + setContext.run(sid, key, value); + res.json({ ok: true, sessionId: sid, key, value, message: "Context saved" }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +// DELETE clear context +router.delete("/", async (req: Request, res: Response) => { + const sessionId = (req.query.sessionId as string) || DEFAULT_SESSION; + + try { + clearContext.run(sessionId); + res.json({ ok: true, message: "Context cleared", sessionId }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +export default router \ No newline at end of file diff --git a/bridge/src/routes/feedback.ts b/bridge/src/routes/feedback.ts new file mode 100644 index 0000000..1b60fd1 --- /dev/null +++ b/bridge/src/routes/feedback.ts @@ -0,0 +1,78 @@ +/** + * feedback.ts — Continuous Learning + * Simple feedback storage + */ + +import { Router, Request, Response } from "express"; +import { randomUUID } from "crypto"; +import db from "../db.js"; + +const router = Router(); + +// Ensure tables exist +const initTables = () => { + try { + db.exec(` + CREATE TABLE IF NOT EXISTS feedback_log ( + id TEXT PRIMARY KEY, + context TEXT NOT NULL, + user_feedback TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + `); + db.exec(` + CREATE TABLE IF NOT EXISTS user_preferences ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT DEFAULT (datetime('now')) + ); + `); + } catch (e) { /* tables may exist */ } +}; +initTables(); + +// Log feedback +router.post("/", async (req: Request, res: Response) => { + const { context, feedback } = req.body; + if (!context || !feedback) { + return res.status(400).json({ error: "context and feedback required" }); + } + const id = randomUUID(); + try { + db.prepare("INSERT INTO feedback_log (id, context, user_feedback) VALUES (?, ?, ?)") + .run(id, context, feedback); + // Simple pattern detection + if (feedback.toLowerCase().includes("short") || feedback.toLowerCase().includes("brief")) { + db.prepare("INSERT OR REPLACE INTO user_preferences (key, value) VALUES ('reply_length', 'brief')").run(); + } + res.json({ ok: true, id }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + +// Get preferences +router.get("/prefer", async (req: Request, res: Response) => { + try { + const prefs = db.prepare("SELECT * FROM user_preferences").all(); + res.json({ preferences: prefs }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + +// Store preference +router.post("/prefer", async (req: Request, res: Response) => { + const { key, value } = req.body; + if (!key || value === undefined) { + return res.status(400).json({ error: "key and value required" }); + } + try { + db.prepare("INSERT OR REPLACE INTO user_preferences (key, value) VALUES (?, ?)").run(key, String(value)); + res.json({ ok: true }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + +export default router; \ No newline at end of file diff --git a/bridge/src/routes/health.ts b/bridge/src/routes/health.ts new file mode 100644 index 0000000..27cd860 --- /dev/null +++ b/bridge/src/routes/health.ts @@ -0,0 +1,75 @@ +/** + * health.ts — GET /tiger/health + * + * Self-healing health checks. Returns system status and can trigger + * auto-restart if critical services are down. + * + * GET /tiger/health?check=true — run full check and restart if needed + * GET /tiger/health — just return status + */ + +import { Router, Request, Response } from "express"; +import { execInSandbox } from "../tiger.js"; + +const router = Router(); + +// Service check definitions +const services = [ + { name: "gateway", port: 18789, path: "/health" }, + { name: "bridge", port: 3456, path: "/tiger/status" }, +] + +async function checkService(name: string, port: number, path: string): Promise<{ + name: string + status: "ok" | "error" + response?: string +}> { + try { + const portMap: Record = { + 18789: "http://127.0.0.1", + 3456: "http://127.0.0.1", + } + const baseUrl = portMap[port] || `http://127.0.0.1:${port}` + const res = await fetch(`${baseUrl}${path}`, { + signal: AbortSignal.timeout(5000), + }) + if (res.ok) { + return { name, status: "ok" } + } + return { name, status: "error", response: `HTTP ${res.status}` } + } catch (err: any) { + return { name, status: "error", response: err.message } + } +} + +router.get("/", async (_req: Request, res: Response) => { + const shouldRestart = _req.query.check === "true" + + const results = await Promise.all( + services.map((s) => checkService(s.name, s.port, s.path)) + ) + + const allHealthy = results.every((r) => r.status === "ok") + + const response = { + ok: true, + timestamp: new Date().toISOString(), + status: allHealthy ? "healthy" : "degraded", + services: results, + } + + // Auto-restart if requested and services are down + if (shouldRestart && !allHealthy) { + const failed = results.filter((r) => r.status === "error").map((r) => r.name) + console.log(`[health] Services down: ${failed.join(", ")}. Triggering restart.`) + + // Trigger restart but don't wait + execInSandbox("docker restart tiger-openclaw 2>/dev/null || true") + .then(() => console.log("[health] Restart triggered")) + .catch(() => console.log("[health] Restart failed")) + } + + res.json(response) +}) + +export default router \ No newline at end of file diff --git a/bridge/src/routes/knowledge.ts b/bridge/src/routes/knowledge.ts new file mode 100644 index 0000000..7c6ea0d --- /dev/null +++ b/bridge/src/routes/knowledge.ts @@ -0,0 +1,126 @@ +/** + * knowledge.ts — Knowledge Graph endpoints + * Uses raw SQL execution to create tables if not exist + */ + +import { Router, Request, Response } from "express"; +import { randomUUID } from "crypto"; +import db from "../db.js"; + +const router = Router(); + +// Ensure tables exist +const initTables = () => { + try { + db.exec(` + CREATE TABLE IF NOT EXISTS knowledge_nodes ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + `); + db.exec(` + CREATE TABLE IF NOT EXISTS knowledge_edges ( + id TEXT PRIMARY KEY, + from_node TEXT NOT NULL, + to_node TEXT NOT NULL, + relationship TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + `); + } catch (e) { /* tables may exist */ } +}; +initTables(); + +// List all nodes +router.get("/", async (req: Request, res: Response) => { + const { q, limit = 50 } = req.query; + try { + let sql = "SELECT * FROM knowledge_nodes"; + const params: string[] = []; + if (q) { + sql += " WHERE name LIKE ? OR description LIKE ?"; + params.push(`%${q}%`, `%${q}%`); + } + sql += " ORDER BY created_at DESC LIMIT ?"; + params.push(String(limit)); + const nodes = db.prepare(sql).all(...params); + res.json({ nodes }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + +// Get node with connections +router.get("/:id", async (req: Request, res: Response) => { + const { id } = req.params; + try { + const node = db.prepare("SELECT * FROM knowledge_nodes WHERE id = ?").get(id); + if (!node) return res.status(404).json({ error: "Not found" }); + // Get all edges for graph + const edges = db.prepare(` + SELECT ke.from_node, ke.to_node, ke.relationship, kn.name as to_name + FROM knowledge_edges ke + JOIN knowledge_nodes kn ON ke.to_node = kn.id + `).all(); + // Get all nodes for graph + const allNodes = db.prepare("SELECT * FROM knowledge_nodes").all(); + res.json({ node, connections: edges, allNodes }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + +// Create node +router.post("/", async (req: Request, res: Response) => { + const { type, name, description } = req.body; + if (!type || !name) return res.status(400).json({ error: "type, name required" }); + const id = randomUUID(); + try { + db.prepare("INSERT INTO knowledge_nodes (id, type, name, description) VALUES (?, ?, ?, ?)") + .run(id, type, name, description || ""); + res.json({ ok: true, id }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + +// Create connection +router.post("/connect", async (req: Request, res: Response) => { + const { from, to, relationship } = req.body; + if (!from || !to || !relationship) { + return res.status(400).json({ error: "from, to, relationship required" }); + } + try { + const id = randomUUID(); + db.prepare("INSERT INTO knowledge_edges (id, from_node, to_node, relationship) VALUES (?, ?, ?, ?)") + .run(id, from, to, relationship); + res.json({ ok: true, id }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + +// Seed initial knowledge +router.post("/seed", async (req: Request, res: Response) => { + const nodes = [ + { t: "person", n: "Manohar", d: "IIT Roorkee, IIM Rohtak, works at Renew Power" }, + { t: "company", n: "Renew Power", d: "India renewable energy, NYSE: RNW" }, + { t: "company", n: "Adani Green", d: "Competitor in renewables" }, + { t: "concept", n: "PE/VC", d: "Career path interest" }, + { t: "concept", n: "Option Trading", d: "Nifty options selling" }, + ]; + try { + for (const x of nodes) { + db.prepare("INSERT OR IGNORE INTO knowledge_nodes (id, type, name, description) VALUES (?, ?, ?, ?)") + .run(randomUUID(), x.t, x.n, x.d); + } + res.json({ ok: true, count: nodes.length }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + +export default router; \ No newline at end of file diff --git a/bridge/src/routes/spawn.ts b/bridge/src/routes/spawn.ts new file mode 100644 index 0000000..b4b1d37 --- /dev/null +++ b/bridge/src/routes/spawn.ts @@ -0,0 +1,66 @@ +/** + * spawn.ts — POST /tiger/spawn + * + * Trigger spawning of sub-agents. This is a placeholder - + * real implementation requires sub-agent permission config. + * + * POST /tiger/spawn + * { agentId: "coder" | "researcher" | "writer" | "pm", task: "..." } + * + * Response: + * { ok: true, sessionId, status: "spawned" | "pending" } + */ + +import { Router, Request, Response } from "express"; +import { execInSandbox } from "../tiger.js"; + +const router = Router(); + +const validAgents = ["coder", "researcher", "writer", "pm"]; + +router.post("/", async (req: Request, res: Response) => { + const { agentId, task } = req.body; + + if (!agentId || !validAgents.includes(agentId)) { + return res.status(400).json({ + ok: false, + error: `Invalid agent. Use: ${validAgents.join(", ")}` + }); + } + + if (!task) { + return res.status(400).json({ ok: false, error: "task is required" }); + } + + try { + // Note: Sub-agent spawning requires config + // This is a placeholder - returns info about what's needed + res.json({ + ok: true, + agentId, + task, + status: "pending", + message: "Sub-agent spawning requires config. Set agents.defaults.subagents in openclaw.json" + }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +// GET available agents +router.get("/agents", (_req: Request, res: Response) => { + res.json({ + ok: true, + agents: validAgents.map(id => ({ + id, + name: id === "coder" ? "Cody" : + id === "researcher" ? "Ethan" : + id === "writer" ? "Cathy" : "Elon", + role: id === "coder" ? "Code" : + id === "researcher" ? "Research" : + id === "writer" ? "Write" : "PM" + })) + }); +}); + +export default router \ No newline at end of file diff --git a/bridge/src/routes/suggestions.ts b/bridge/src/routes/suggestions.ts new file mode 100644 index 0000000..280009f --- /dev/null +++ b/bridge/src/routes/suggestions.ts @@ -0,0 +1,66 @@ +/** + * suggestions.ts — GET /tiger/suggestions + * + * Returns AI-powered suggestions based on current context. + * This is a placeholder - real implementation would use the LLM. + * + * GET /tiger/suggestions + * ?context=current_task,project,dashboard + * + * Response: + * { ok: true, suggestions: [{ text, action, priority }] } + */ + +import { Router, Request, Response } from "express"; +import { execInSandbox } from "../tiger.js"; + +const router = Router(); + +// Default suggestions when no AI +const defaultSuggestions = [ + { text: "Check active tasks", action: "/tasks", priority: "high" }, + { text: "View project status", action: "/projects", priority: "medium" }, + { text: "Check system health", action: "/api/tiger/status", priority: "medium" }, +] + +router.get("/", async (_req: Request, res: Response) => { + try { + // Get active tasks + const tasksResult = await execInSandbox("cat /home/node/.openclaw/workspace/TASKS.md"); + + // Count in-progress tasks + let hasActiveWork = false + if (tasksResult.stdout.includes("in-progress")) { + hasActiveWork = true + } + + const suggestions = [] + + if (hasActiveWork) { + suggestions.push({ + text: "Continue with active task", + action: "/projects", + priority: "high" + }) + } + + // Adddefaults + suggestions.push(...defaultSuggestions.slice(0, 3)) + + res.json({ + ok: true, + suggestions: suggestions.slice(0, 5), + hasActiveWork + }) + } catch (err: any) { + // Fallback to defaults + res.json({ + ok: true, + suggestions: defaultSuggestions, + hasActiveWork: false, + error: err.message + }) + } +}) + +export default router \ No newline at end of file diff --git a/bridge/src/routes/telegram-webhook.ts b/bridge/src/routes/telegram-webhook.ts new file mode 100644 index 0000000..1e09151 --- /dev/null +++ b/bridge/src/routes/telegram-webhook.ts @@ -0,0 +1,74 @@ +/** + * telegram-webhook.ts — Handle Telegram webhooks and mirror to chat history + * + * Receives Telegram message updates and mirrors them to the chat_messages table. + * This enables Telegram ↔ WebChat history sync. + * + * POST /tiger/telegram-webhook + * Body: Telegram Update object (https://core.telegram.org/bots/api#update) + * Response: OK + */ + +import { Router, Request, Response } from "express"; +import db from "../db.js"; + +const router = Router(); + +const DEFAULT_SESSION_ID = "agent:main:main"; + +const insertMessage = db.prepare(` + INSERT INTO chat_messages (session_id, role, content, meta) + VALUES (?, ?, ?, ?) +`); + +// POST /tiger/telegram-webhook — receive Telegram updates +router.post("/", async (req: Request, res: Response) => { + try { + const update = req.body; + + // Handle message updates + if (update.message) { + const msg = update.message; + const chatId = msg.chat?.id?.toString(); + const text = msg.text; + const from = msg.from; + + if (text && chatId) { + // Store user message + const meta = JSON.stringify({ + source: "telegram", + chatId: chatId, + messageId: msg.message_id, + from: from ? { + id: from.id, + firstName: from.first_name, + lastName: from.last_name, + username: from.username + } : null, + timestamp: new Date().toISOString() + }); + + insertMessage.run(DEFAULT_SESSION_ID, "user", text, meta); + + // If it's a reply (has reply_to_message), store agent response too + if (msg.reply_to_message) { + const replyText = msg.reply_to_message.text; + const replyMeta = JSON.stringify({ + source: "telegram", + chatId: chatId, + replyToMessageId: msg.message_id, + timestamp: new Date().toISOString() + }); + insertMessage.run(DEFAULT_SESSION_ID, "agent", replyText, replyMeta); + } + } + } + + res.json({ ok: true }); + } catch (err: any) { + console.error("[telegram-webhook] Error:", err.message); + res.status(500).json({ ok: false, error: err.message }); + } +}); + +export default router; \ No newline at end of file