feat(bridge): add alerts, context, spawn, suggestions, health, feedback, knowledge, chat-mirror, telegram-webhook, angel/positions routes + wire in index.ts
This commit is contained in:
parent
2c0bf6c999
commit
e00c6db544
11 changed files with 877 additions and 0 deletions
|
|
@ -63,6 +63,11 @@ import express from "express";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import { authMiddleware } from "./auth.js";
|
import { authMiddleware } from "./auth.js";
|
||||||
import statusRouter from "./routes/status.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 logsRouter from "./routes/logs.js";
|
||||||
import execRouter from "./routes/exec.js";
|
import execRouter from "./routes/exec.js";
|
||||||
import configRouter from "./routes/config.js";
|
import configRouter from "./routes/config.js";
|
||||||
|
|
@ -120,6 +125,13 @@ app.get("/health", (_req, res) => {
|
||||||
|
|
||||||
// Tiger endpoints — all scoped under /tiger
|
// Tiger endpoints — all scoped under /tiger
|
||||||
app.use("/tiger/status", statusRouter);
|
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/logs", logsRouter); // SSE stream
|
||||||
app.use("/tiger/exec", execRouter);
|
app.use("/tiger/exec", execRouter);
|
||||||
app.use("/tiger/config", configRouter);
|
app.use("/tiger/config", configRouter);
|
||||||
|
|
@ -141,6 +153,9 @@ app.use("/tiger/deploy-dashboard", deployRouter);
|
||||||
app.use("/tiger/route-task", routeTaskRouter);
|
app.use("/tiger/route-task", routeTaskRouter);
|
||||||
app.use("/tiger/keys", keysRouter);
|
app.use("/tiger/keys", keysRouter);
|
||||||
app.use("/tiger/chat", (await import("./routes/chat.js")).default);
|
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
|
// Gateway proxy — forwards to gateway inside Tiger container
|
||||||
// This is needed because the dashboard runs in Dokploy which can't reach the container directly
|
// This is needed because the dashboard runs in Dokploy which can't reach the container directly
|
||||||
|
|
|
||||||
88
bridge/src/routes/alerts.ts
Normal file
88
bridge/src/routes/alerts.ts
Normal file
|
|
@ -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<Alert[]> {
|
||||||
|
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
|
||||||
138
bridge/src/routes/angel/positions.ts
Normal file
138
bridge/src/routes/angel/positions.ts
Normal file
|
|
@ -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<string | null> {
|
||||||
|
// 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<any[]> {
|
||||||
|
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 };
|
||||||
68
bridge/src/routes/chat-mirror.ts
Normal file
68
bridge/src/routes/chat-mirror.ts
Normal file
|
|
@ -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;
|
||||||
83
bridge/src/routes/context.ts
Normal file
83
bridge/src/routes/context.ts
Normal file
|
|
@ -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<string, string> = {};
|
||||||
|
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
|
||||||
78
bridge/src/routes/feedback.ts
Normal file
78
bridge/src/routes/feedback.ts
Normal file
|
|
@ -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;
|
||||||
75
bridge/src/routes/health.ts
Normal file
75
bridge/src/routes/health.ts
Normal file
|
|
@ -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<number, string> = {
|
||||||
|
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
|
||||||
126
bridge/src/routes/knowledge.ts
Normal file
126
bridge/src/routes/knowledge.ts
Normal file
|
|
@ -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;
|
||||||
66
bridge/src/routes/spawn.ts
Normal file
66
bridge/src/routes/spawn.ts
Normal file
|
|
@ -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
|
||||||
66
bridge/src/routes/suggestions.ts
Normal file
66
bridge/src/routes/suggestions.ts
Normal file
|
|
@ -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
|
||||||
74
bridge/src/routes/telegram-webhook.ts
Normal file
74
bridge/src/routes/telegram-webhook.ts
Normal file
|
|
@ -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;
|
||||||
Loading…
Add table
Reference in a new issue