feat(bridge): Telegram polling, file-based tasks/projects, cron/keys/deploy routes
- routes/notify.ts: Telegram delivery endpoint POST /tiger/notify - routes/tasks-file.ts: TASKS.md/PROJECTS.md reader (JSON block parser — P0 fix) Parser reads only from TASKS_JSON fenced block; returns 502 if block absent - routes/cron.ts: GET /tiger/cron + POST /tiger/cron/:id/run - routes/keys.ts: GET /tiger/keys (key presence map, no values exposed) - routes/agents-activity.ts: GET /tiger/agents/:id/files - routes/route-task.ts: POST /tiger/route-task (LLM router) - routes/deploy.ts: POST /tiger/deploy-dashboard (path: OpenClawDashboard) - db.ts: comment Reserved telegram columns (future task-from-Telegram)
This commit is contained in:
parent
fbeba9e300
commit
a65902edf9
8 changed files with 748 additions and 4 deletions
|
|
@ -48,6 +48,9 @@ db.exec(`
|
|||
status TEXT DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium',
|
||||
assigned_agent TEXT,
|
||||
agent_reason TEXT,
|
||||
telegram_chat_id TEXT, -- Reserved: future task-from-Telegram
|
||||
telegram_message_id TEXT, -- Reserved: future task-from-Telegram
|
||||
progress INTEGER DEFAULT 0,
|
||||
tags TEXT DEFAULT '[]',
|
||||
notes TEXT DEFAULT '',
|
||||
|
|
@ -99,6 +102,14 @@ db.exec(`
|
|||
CREATE INDEX IF NOT EXISTS idx_outputs_task ON outputs(task_id);
|
||||
`);
|
||||
|
||||
// ─── Migrations ──────────────────────────────────────────────────────────────
|
||||
// Columns added after initial schema creation. ALTER TABLE is idempotent via
|
||||
// try/catch — SQLite raises "duplicate column" on repeated runs, which we ignore.
|
||||
try { db.exec("ALTER TABLE tasks ADD COLUMN agent_reason TEXT"); } catch { /* already exists */ }
|
||||
// Reserved columns — future task-from-Telegram feature
|
||||
try { db.exec("ALTER TABLE tasks ADD COLUMN telegram_chat_id TEXT"); } catch { /* already exists */ }
|
||||
try { db.exec("ALTER TABLE tasks ADD COLUMN telegram_message_id TEXT"); } catch { /* already exists */ }
|
||||
|
||||
// ─── Helper to generate IDs ─────────────────────────────────────────────────
|
||||
|
||||
export function generateId(prefix: string): string {
|
||||
|
|
@ -176,17 +187,18 @@ export const tasks = {
|
|||
},
|
||||
|
||||
create(data: {
|
||||
project_id: string;
|
||||
project_id: string | null;
|
||||
title: string;
|
||||
description?: string;
|
||||
priority?: string;
|
||||
assigned_agent?: string;
|
||||
agent_reason?: string;
|
||||
parent_task_id?: string;
|
||||
}): unknown {
|
||||
const id = generateId("task");
|
||||
db.prepare(`
|
||||
INSERT INTO tasks (id, project_id, parent_task_id, title, description, priority, assigned_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO tasks (id, project_id, parent_task_id, title, description, priority, assigned_agent, agent_reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
data.project_id,
|
||||
|
|
@ -194,7 +206,8 @@ export const tasks = {
|
|||
data.title,
|
||||
data.description || "",
|
||||
data.priority || "medium",
|
||||
data.assigned_agent || null
|
||||
data.assigned_agent || null,
|
||||
data.agent_reason || null
|
||||
);
|
||||
return tasks.findById(id);
|
||||
},
|
||||
|
|
@ -209,6 +222,7 @@ export const tasks = {
|
|||
tags: string;
|
||||
notes: string;
|
||||
due_date: string;
|
||||
agent_reason: string;
|
||||
}>): unknown | undefined {
|
||||
const updates: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
|
@ -222,6 +236,8 @@ export const tasks = {
|
|||
if (data.tags !== undefined) { updates.push("tags = ?"); values.push(data.tags); }
|
||||
if (data.notes !== undefined) { updates.push("notes = ?"); values.push(data.notes); }
|
||||
if (data.due_date !== undefined) { updates.push("due_date = ?"); values.push(data.due_date); }
|
||||
if (data.agent_reason !== undefined) { updates.push("agent_reason = ?"); values.push(data.agent_reason); }
|
||||
if (data.status !== undefined) { updates.push("status = ?"); values.push(data.status); }
|
||||
|
||||
if (updates.length === 0) return tasks.findById(id);
|
||||
|
||||
|
|
|
|||
108
bridge/src/routes/agents-activity.ts
Normal file
108
bridge/src/routes/agents-activity.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* routes/agents-activity.ts — Transform agents data into per-agent activity view
|
||||
*
|
||||
* GET /tiger/agents/activity
|
||||
* Uses execInSandbox to call /tiger/agents from inside OpenClaw container,
|
||||
* then transforms to per-agent activity cards.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { execInSandbox } from "../tiger.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
function agentName(subAgentId: string | null | undefined): string {
|
||||
if (!subAgentId) return "Tiger";
|
||||
const map: Record<string, string> = {
|
||||
"main": "Tiger",
|
||||
"coder": "Cody",
|
||||
"researcher": "Ethan",
|
||||
"writer": "Cathy",
|
||||
"pm": "Elon",
|
||||
};
|
||||
return map[subAgentId] || subAgentId;
|
||||
}
|
||||
|
||||
function timeAgo(timestamp: number | null): string {
|
||||
if (!timestamp) return "never";
|
||||
const seconds = Math.floor((Date.now() / 1000) - timestamp);
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
router.get("/", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
// Use execInSandbox to call /tiger/agents from inside OpenClaw container
|
||||
const { stdout } = await execInSandbox(
|
||||
`curl -s "http://172.17.0.1:3456/tiger/agents" -H "Authorization: Bearer 14fb879429386b69beac339bbd98e43011ec29485da17592410da34ed97e0236"`
|
||||
);
|
||||
|
||||
let rawData: any;
|
||||
try {
|
||||
rawData = JSON.parse(stdout);
|
||||
} catch {
|
||||
return res.status(500).json({ ok: false, error: "Could not parse agents response" });
|
||||
}
|
||||
|
||||
const rawSessions: any[] = rawData.sessions || [];
|
||||
|
||||
// Build per-agent status
|
||||
const agentMap: Record<string, any> = {
|
||||
"Tiger": { id: "main", name: "Tiger", subAgentId: "main", sessions: [], lastActive: null, isRunning: false, currentTask: "" },
|
||||
"Cody": { id: "coder", name: "Cody", subAgentId: "coder", sessions: [], lastActive: null, isRunning: false, currentTask: "" },
|
||||
"Ethan": { id: "researcher", name: "Ethan", subAgentId: "researcher", sessions: [], lastActive: null, isRunning: false, currentTask: "" },
|
||||
"Cathy": { id: "writer", name: "Cathy", subAgentId: "writer", sessions: [], lastActive: null, isRunning: false, currentTask: "" },
|
||||
"Elon": { id: "pm", name: "Elon", subAgentId: "pm", sessions: [], lastActive: null, isRunning: false, currentTask: "" },
|
||||
};
|
||||
|
||||
for (const session of rawSessions) {
|
||||
const name = agentName(session.subAgentId);
|
||||
if (!agentMap[name]) continue;
|
||||
|
||||
agentMap[name].sessions.push({
|
||||
sessionKey: session.sessionKey,
|
||||
label: session.label,
|
||||
lastMessage: session.lastMessage || "",
|
||||
model: session.model,
|
||||
running: session.running || false,
|
||||
});
|
||||
|
||||
if (session.running) {
|
||||
agentMap[name].isRunning = true;
|
||||
agentMap[name].currentTask = session.label || "Running";
|
||||
agentMap[name].lastActive = timeAgo(session.lastMessageTime);
|
||||
}
|
||||
}
|
||||
|
||||
// Set lastActive for non-running agents based on most recent message
|
||||
for (const name of Object.keys(agentMap)) {
|
||||
const a = agentMap[name];
|
||||
if (!a.isRunning && a.sessions.length > 0) {
|
||||
a.sessions.sort((x: any, y: any) => (y.lastMessageTime || 0) - (x.lastMessageTime || 0));
|
||||
a.lastActive = timeAgo(a.sessions[0].lastMessageTime);
|
||||
}
|
||||
}
|
||||
|
||||
const agents = Object.values(agentMap).map((a: any) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
subAgentId: a.subAgentId,
|
||||
status: a.isRunning ? "active" : (a.sessions.length > 0 ? "idle" : "offline"),
|
||||
currentTask: a.currentTask,
|
||||
lastActive: a.lastActive,
|
||||
sessionCount: a.sessions.length,
|
||||
isRunning: a.isRunning,
|
||||
}));
|
||||
|
||||
res.json({ ok: true, count: agents.length, agents, updated: new Date().toISOString() });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
77
bridge/src/routes/cron.ts
Normal file
77
bridge/src/routes/cron.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { Router, Request, Response } from "express";
|
||||
import { execInSandbox } from "../tiger.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// cron/jobs.json lives inside the container at a known path.
|
||||
// We read it directly — openclaw cron list --json requires an active gateway
|
||||
// WebSocket which may not always be up.
|
||||
const CRON_JOBS_PATH = "/home/node/.openclaw/cron/jobs.json";
|
||||
|
||||
function formatCronJobs(raw: any): any[] {
|
||||
const jobs = raw?.jobs ?? [];
|
||||
return jobs.map((j: any) => ({
|
||||
id: j.id,
|
||||
name: j.name ?? j.id,
|
||||
schedule: j.schedule?.expr ?? "",
|
||||
tz: j.schedule?.tz ?? "UTC",
|
||||
enabled: j.enabled ?? true,
|
||||
agentId: j.agentId ?? "main",
|
||||
lastRun: j.state?.lastRunAtMs
|
||||
? {
|
||||
at: new Date(j.state.lastRunAtMs).toISOString(),
|
||||
status: j.state.lastRunStatus ?? j.state.lastStatus ?? "unknown",
|
||||
durationMs: j.state.lastDurationMs,
|
||||
errors: j.state.consecutiveErrors ?? 0,
|
||||
lastError: j.state.lastError ?? null,
|
||||
}
|
||||
: null,
|
||||
nextRun: j.state?.nextRunAtMs
|
||||
? new Date(j.state.nextRunAtMs).toISOString()
|
||||
: null,
|
||||
message: j.payload?.message?.slice(0, 120) ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
// GET /tiger/cron — list cron jobs directly from jobs.json
|
||||
router.get("/", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const { stdout } = await execInSandbox(`cat ${CRON_JOBS_PATH} 2>/dev/null || echo '{}'`);
|
||||
let raw: any = {};
|
||||
try { raw = JSON.parse(stdout.trim() || "{}"); } catch { raw = {}; }
|
||||
|
||||
const jobs = formatCronJobs(raw);
|
||||
|
||||
// Scheduler meta: enabled + nextWakeAt (from first job with nextRunAtMs)
|
||||
const nextWake = jobs.find((j) => j.nextRun)?.nextRun ?? null;
|
||||
const hasErrors = jobs.some((j) => (j.lastRun?.errors ?? 0) > 0);
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
jobs,
|
||||
status: {
|
||||
enabled: true,
|
||||
jobCount: jobs.length,
|
||||
nextWake,
|
||||
hasErrors,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /tiger/cron/:id/run — trigger a cron job immediately via gateway
|
||||
router.post("/:id/run", async (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const { stdout, stderr } = await execInSandbox(
|
||||
`openclaw cron run ${id} 2>&1 || true`
|
||||
);
|
||||
res.json({ ok: true, output: (stdout || stderr).slice(0, 500) });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
40
bridge/src/routes/deploy.ts
Normal file
40
bridge/src/routes/deploy.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* deploy.ts — POST /tiger/deploy-dashboard
|
||||
*
|
||||
* Triggers a full dashboard rebuild + service restart on the host.
|
||||
* Called by Tiger (from inside container) after editing dashboard source files.
|
||||
*
|
||||
* Flow:
|
||||
* Tiger edits /home/node/dashboard/... (bind-mounted from /root/OpenClawDashboard)
|
||||
* Tiger calls POST /tiger/deploy-dashboard
|
||||
* Bridge runs /root/scripts/rebuild-dashboard.sh on HOST
|
||||
* Returns build output so Tiger can confirm success or report errors
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { execOnHost } from '../tiger.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/', async (_req, res) => {
|
||||
try {
|
||||
console.log('[deploy] Dashboard deploy triggered by Tiger');
|
||||
const result = await execOnHost(
|
||||
'/root/scripts/rebuild-dashboard.sh',
|
||||
120_000 // 2 min timeout — Next.js builds can be slow
|
||||
);
|
||||
const success = result.exitCode === 0;
|
||||
res.json({
|
||||
ok: success,
|
||||
message: success ? 'Dashboard deployed successfully' : 'Deploy failed',
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: result.exitCode,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('[deploy] Error:', err.message);
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
243
bridge/src/routes/keys.ts
Normal file
243
bridge/src/routes/keys.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
/**
|
||||
* routes/keys.ts — API key management for the bridge
|
||||
*
|
||||
* Persists API keys (Anthropic, OpenRouter, Telegram) to the bridge's .env
|
||||
* file so they survive restarts. The settings page in the dashboard calls
|
||||
* these endpoints.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /tiger/keys — return key presence (NEVER the values themselves)
|
||||
* PATCH /tiger/keys — set one or more keys; { ANTHROPIC_API_KEY?, ... }
|
||||
* DELETE /tiger/keys/:name — clear a single key
|
||||
*
|
||||
* Security model:
|
||||
* - GETs return only { isSet: true|false } per key. The actual value
|
||||
* never leaves the server. This means the UI shows "Anthropic key
|
||||
* configured ✓" rather than echoing the key back.
|
||||
* - PATCH writes the .env file with the new values. systemd then needs
|
||||
* a restart to pick them up — we do NOT auto-restart from this route
|
||||
* because that would kill the very HTTP request that triggered the
|
||||
* change. The UI tells the user to click "Restart bridge" after saving.
|
||||
* - .env is owned by uid 1000 (the bridge user) and chmod 600.
|
||||
*
|
||||
* We do NOT support env vars other than the documented allowlist below.
|
||||
* That keeps an attacker who finds a way past auth from injecting
|
||||
* arbitrary env into a service restart.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// .env lives at bridge/.env — i.e. two directories up from src/routes.
|
||||
// __dirname when running compiled code is dist/routes, when running via tsx
|
||||
// it's src/routes. Both resolve to the same target via "../../".
|
||||
const ENV_PATH = path.resolve(__dirname, "../../.env");
|
||||
|
||||
// Allowlist — only these keys may be set/cleared via this endpoint.
|
||||
const ALLOWED_KEYS = [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"TELEGRAM_BOT_TOKEN",
|
||||
"TELEGRAM_CHAT_ID",
|
||||
"TIGER_ROUTER_MODEL",
|
||||
] as const;
|
||||
type AllowedKey = (typeof ALLOWED_KEYS)[number];
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read the .env file as a Map<key, value>. Lines that aren't KEY=VALUE
|
||||
* (comments, blanks) are kept verbatim so we can preserve them on write.
|
||||
*
|
||||
* Returns { entries, raw } where:
|
||||
* entries is the parsed map of KEY → VALUE
|
||||
* raw is the original line array (so we can reconstruct on write)
|
||||
*/
|
||||
async function readEnvFile(): Promise<{
|
||||
entries: Map<string, string>;
|
||||
raw: string[];
|
||||
}> {
|
||||
let content = "";
|
||||
try {
|
||||
content = await readFile(ENV_PATH, "utf-8");
|
||||
} catch {
|
||||
// Missing .env is fine — treat as empty
|
||||
return { entries: new Map(), raw: [] };
|
||||
}
|
||||
const raw = content.split("\n");
|
||||
const entries = new Map<string, string>();
|
||||
for (const line of raw) {
|
||||
// Skip comments and blanks
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq < 0) continue;
|
||||
const k = line.slice(0, eq).trim();
|
||||
const v = line.slice(eq + 1).trim();
|
||||
entries.set(k, v);
|
||||
}
|
||||
return { entries, raw };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct the .env file with `updates` applied. Existing lines are kept
|
||||
* (including comments and order); updated keys are replaced in place;
|
||||
* new keys are appended at the bottom.
|
||||
*/
|
||||
function applyUpdates(
|
||||
raw: string[],
|
||||
updates: Map<string, string | null>,
|
||||
): string {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
|
||||
for (const line of raw) {
|
||||
const trimmed = line.trim();
|
||||
// Pass through comments and blanks unchanged
|
||||
if (!trimmed || trimmed.startsWith("#")) {
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
const eq = line.indexOf("=");
|
||||
if (eq < 0) {
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
const k = line.slice(0, eq).trim();
|
||||
|
||||
if (updates.has(k)) {
|
||||
const v = updates.get(k);
|
||||
if (v === null) {
|
||||
// Cleared: replace with empty value but keep the key so the
|
||||
// structure of .env is preserved across restarts.
|
||||
out.push(`${k}=`);
|
||||
} else {
|
||||
out.push(`${k}=${v}`);
|
||||
}
|
||||
seen.add(k);
|
||||
} else {
|
||||
// Not being updated — keep as-is
|
||||
out.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Append any new keys not already in the file
|
||||
for (const [k, v] of updates) {
|
||||
if (seen.has(k)) continue;
|
||||
if (v === null) continue; // don't bother adding cleared keys
|
||||
out.push(`${k}=${v}`);
|
||||
}
|
||||
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
// ─── GET /tiger/keys ───────────────────────────────────────────────────────
|
||||
// Returns presence-only: { ANTHROPIC_API_KEY: { isSet: true }, ... }
|
||||
router.get("/", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const { entries } = await readEnvFile();
|
||||
const result: Record<string, { isSet: boolean; preview?: string }> = {};
|
||||
|
||||
for (const k of ALLOWED_KEYS) {
|
||||
const v = entries.get(k) ?? "";
|
||||
// For TIGER_ROUTER_MODEL, the value is non-secret — return it directly.
|
||||
// For everything else, only return presence.
|
||||
if (k === "TIGER_ROUTER_MODEL") {
|
||||
result[k] = { isSet: v.length > 0, preview: v };
|
||||
} else {
|
||||
result[k] = { isSet: v.length > 0 };
|
||||
}
|
||||
}
|
||||
res.json({ ok: true, keys: result });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── PATCH /tiger/keys ─────────────────────────────────────────────────────
|
||||
// Body: partial object of allowed keys → string (set) or null (clear).
|
||||
// Example: { ANTHROPIC_API_KEY: "sk-ant-...", TELEGRAM_CHAT_ID: null }
|
||||
router.patch("/", async (req: Request, res: Response) => {
|
||||
const body = req.body as Record<string, unknown>;
|
||||
if (!body || typeof body !== "object") {
|
||||
return res.status(400).json({ ok: false, error: "Body must be an object" });
|
||||
}
|
||||
|
||||
const updates = new Map<string, string | null>();
|
||||
for (const [k, v] of Object.entries(body)) {
|
||||
if (!(ALLOWED_KEYS as readonly string[]).includes(k)) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: `Key not allowed: ${k}. Allowed: ${ALLOWED_KEYS.join(", ")}`,
|
||||
});
|
||||
}
|
||||
if (v === null) {
|
||||
updates.set(k, null);
|
||||
} else if (typeof v === "string") {
|
||||
// Reject control chars and newlines that would break .env format
|
||||
if (/[\r\n]/.test(v)) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: `Value for ${k} contains newline characters`,
|
||||
});
|
||||
}
|
||||
updates.set(k, v);
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: `Value for ${k} must be a string or null`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { raw } = await readEnvFile();
|
||||
const newContent = applyUpdates(raw, updates);
|
||||
await writeFile(ENV_PATH, newContent, { encoding: "utf-8", mode: 0o600 });
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
updated: Array.from(updates.keys()),
|
||||
message:
|
||||
"Keys saved to .env. Restart tiger-bridge for changes to take effect.",
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("[keys] PATCH failed:", err);
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── DELETE /tiger/keys/:name ──────────────────────────────────────────────
|
||||
// Clear a single key (sets it to empty in .env).
|
||||
router.delete("/:name", async (req: Request, res: Response) => {
|
||||
const name = req.params.name;
|
||||
if (!(ALLOWED_KEYS as readonly string[]).includes(name)) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: `Key not allowed: ${name}`,
|
||||
});
|
||||
}
|
||||
try {
|
||||
const { raw } = await readEnvFile();
|
||||
const updates = new Map<string, string | null>([[name, null]]);
|
||||
const newContent = applyUpdates(raw, updates);
|
||||
await writeFile(ENV_PATH, newContent, { encoding: "utf-8", mode: 0o600 });
|
||||
res.json({
|
||||
ok: true,
|
||||
cleared: name,
|
||||
message: "Key cleared. Restart tiger-bridge for changes to take effect.",
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("[keys] DELETE failed:", err);
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
86
bridge/src/routes/notify.ts
Normal file
86
bridge/src/routes/notify.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { Router, Request, Response } from "express";
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const OPENCLAW_CONFIG_PATH =
|
||||
process.env.OPENCLAW_CONFIG_PATH ||
|
||||
"/var/lib/docker/volumes/tiger_tiger-config/_data/openclaw.json";
|
||||
|
||||
function getBotToken(): string {
|
||||
if (process.env.TELEGRAM_BOT_TOKEN?.trim()) return process.env.TELEGRAM_BOT_TOKEN.trim();
|
||||
try {
|
||||
const cfg = JSON.parse(readFileSync(OPENCLAW_CONFIG_PATH, "utf-8"));
|
||||
return cfg?.channels?.telegram?.botToken ?? "";
|
||||
} catch { return ""; }
|
||||
}
|
||||
|
||||
function getChatId(): string {
|
||||
if (process.env.TELEGRAM_CHAT_ID?.trim()) return process.env.TELEGRAM_CHAT_ID.trim();
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /tiger/notify
|
||||
* Body: { message: string, chatId?: string }
|
||||
*
|
||||
* Sends a Telegram message via the bridge's bot token.
|
||||
* Called by Tiger's cron jobs (via curl from inside the container)
|
||||
* since OpenClaw's native Telegram channel is disabled (bridge owns polling).
|
||||
*
|
||||
* curl example from inside container:
|
||||
* curl -s -X POST http://172.17.0.1:3456/tiger/notify \
|
||||
* -H "Content-Type: application/json" \
|
||||
* -H "Authorization: Bearer $BRIDGE_TOKEN" \
|
||||
* -d '{"message":"HEARTBEAT_OK"}'
|
||||
*/
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
const { message, chatId: overrideChatId } = req.body as {
|
||||
message?: string;
|
||||
chatId?: string;
|
||||
};
|
||||
|
||||
if (!message?.trim()) {
|
||||
return res.status(400).json({ ok: false, error: "message is required" });
|
||||
}
|
||||
|
||||
const token = getBotToken();
|
||||
const chatId = overrideChatId || getChatId();
|
||||
|
||||
if (!token) {
|
||||
return res.status(503).json({ ok: false, error: "No bot token configured" });
|
||||
}
|
||||
if (!chatId) {
|
||||
return res.status(503).json({
|
||||
ok: false,
|
||||
error: "No TELEGRAM_CHAT_ID in bridge .env — set it so Tiger knows where to send notifications",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const tgRes = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: message.slice(0, 4096),
|
||||
parse_mode: "Markdown",
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
const data = await tgRes.json() as any;
|
||||
if (!data.ok) {
|
||||
console.error("[notify] Telegram error:", data.description);
|
||||
return res.status(502).json({ ok: false, error: data.description });
|
||||
}
|
||||
|
||||
console.log(`[notify] Sent to chat ${chatId}: ${message.slice(0, 60)}…`);
|
||||
res.json({ ok: true, messageId: data.result?.message_id });
|
||||
} catch (err: any) {
|
||||
console.error("[notify] fetch failed:", err.message);
|
||||
res.status(502).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
44
bridge/src/routes/route-task.ts
Normal file
44
bridge/src/routes/route-task.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* routes/route-task.ts — Standalone routing endpoint
|
||||
*
|
||||
* POST /tiger/route-task
|
||||
* Body: { text: string }
|
||||
* Returns: { ok: true, agent: AgentId, reason: string }
|
||||
*
|
||||
* This is a thin HTTP wrapper around classifyAgent() from lib/llm.ts.
|
||||
* It exists so the dashboard (or external tools / Telegram) can ask
|
||||
* "where would you route this?" without creating a task.
|
||||
*
|
||||
* The actual task-creation flow in projects.ts and dispatch.ts will
|
||||
* import classifyAgent directly — they don't need to round-trip through
|
||||
* this endpoint. So this route is for the UI's "preview routing" affordance.
|
||||
*
|
||||
* Failure mode: classifyAgent never throws. The HTTP response will
|
||||
* always be 200 with { agent, reason }. If reason starts with
|
||||
* "router_unavailable:" the UI can show a warning banner.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { classifyAgent } from "../lib/llm.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
const { text } = req.body as { text?: string };
|
||||
|
||||
if (typeof text !== "string" || !text.trim()) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: "Body.text is required and must be a non-empty string",
|
||||
});
|
||||
}
|
||||
|
||||
// classifyAgent has its own try/catch — we never get an exception here,
|
||||
// we just get a result with a "router_unavailable:" reason if the LLM
|
||||
// call failed. That's the contract.
|
||||
const result = await classifyAgent(text);
|
||||
|
||||
res.json({ ok: true, ...result });
|
||||
});
|
||||
|
||||
export default router;
|
||||
130
bridge/src/routes/tasks-file.ts
Normal file
130
bridge/src/routes/tasks-file.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* routes/tasks-file.ts — Read tasks and projects from Tiger's markdown files
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /tiger/file-tasks — all tasks from TASKS.md
|
||||
* GET /tiger/file-tasks/active — only active/pending-action tasks
|
||||
* GET /tiger/file-tasks/completed — only completed tasks
|
||||
* GET /tiger/file-tasks/projects — all projects from PROJECTS.md
|
||||
*
|
||||
* Parser contract (TASKS.md):
|
||||
* Tiger maintains a fenced ```json TASKS ... ``` block at the bottom of
|
||||
* TASKS.md. The parser reads ONLY from that block — no regex over markdown.
|
||||
* If the block is absent, the endpoint returns HTTP 502 with a clear error.
|
||||
* Tiger's MEMORY.md contains the rule: always emit the TASKS_JSON block
|
||||
* on every TASKS.md write.
|
||||
*
|
||||
* Task JSON schema per entry:
|
||||
* { id, title, agent, status, section, note?, age?, project? }
|
||||
* section values: "pending-action" | "in-progress" | "completed"
|
||||
* status values: "pending-action" | "in-progress" | "blocked" | "done"
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { execInSandbox } from "../tiger.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ── Parser: read TASKS_JSON fenced block from TASKS.md ───────────────────────
|
||||
function parseTasksJsonBlock(stdout: string): any[] {
|
||||
// Match: ```json\nTASKS\n<json>\n```
|
||||
const match = stdout.match(/```json\s+TASKS\s*\n([\s\S]+?)\n```/);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
"TASKS.md missing TASKS_JSON block. Tiger must emit:\n" +
|
||||
"```json\nTASKS\n[...]\n```\n" +
|
||||
"at the bottom of every TASKS.md write."
|
||||
);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(match[1]);
|
||||
} catch (e: any) {
|
||||
throw new Error(`TASKS_JSON block is not valid JSON: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Parser: projects from PROJECTS.md ────────────────────────────────────────
|
||||
function parseProjectsMarkdown(stdout: string) {
|
||||
const projects: any[] = [];
|
||||
const lines = stdout.split("\n");
|
||||
let inActive = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("## Active Projects")) { inActive = true; continue; }
|
||||
if (trimmed.startsWith("## Completed Projects")) break;
|
||||
|
||||
if (inActive && trimmed.match(/^\| \d/)) {
|
||||
const cols = trimmed.split("|").map((c: string) => c.trim()).filter(Boolean);
|
||||
if (cols.length >= 5) {
|
||||
projects.push({
|
||||
id: cols[0],
|
||||
name: cols[1],
|
||||
description: cols[2],
|
||||
created: cols[3],
|
||||
tasks_count: cols[4],
|
||||
status: cols[5] || "active",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return projects;
|
||||
}
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /tiger/file-tasks — all tasks
|
||||
router.get("/", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { stdout } = await execInSandbox("cat /home/node/.openclaw/workspace/TASKS.md");
|
||||
const allTasks = parseTasksJsonBlock(stdout);
|
||||
const projectFilter = (req.query.project as string || "").trim().toLowerCase();
|
||||
const filtered = projectFilter
|
||||
? allTasks.filter((t: any) => t.project?.toLowerCase().includes(projectFilter))
|
||||
: allTasks;
|
||||
res.json({ ok: true, source: "TASKS.md", count: filtered.length, tasks: filtered });
|
||||
} catch (err: any) {
|
||||
const status = err.message?.includes("missing TASKS_JSON") ? 502 : 500;
|
||||
res.status(status).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /tiger/file-tasks/active — active + pending-action tasks only
|
||||
router.get("/active", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const { stdout } = await execInSandbox("cat /home/node/.openclaw/workspace/TASKS.md");
|
||||
const tasks = parseTasksJsonBlock(stdout).filter(
|
||||
(t: any) => t.section === "in-progress" || t.section === "pending-action"
|
||||
);
|
||||
res.json({ ok: true, source: "TASKS.md", count: tasks.length, tasks });
|
||||
} catch (err: any) {
|
||||
const status = err.message?.includes("missing TASKS_JSON") ? 502 : 500;
|
||||
res.status(status).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /tiger/file-tasks/completed — completed tasks only
|
||||
router.get("/completed", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const { stdout } = await execInSandbox("cat /home/node/.openclaw/workspace/TASKS.md");
|
||||
const tasks = parseTasksJsonBlock(stdout).filter((t: any) => t.section === "completed");
|
||||
res.json({ ok: true, source: "TASKS.md", count: tasks.length, tasks });
|
||||
} catch (err: any) {
|
||||
const status = err.message?.includes("missing TASKS_JSON") ? 502 : 500;
|
||||
res.status(status).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /tiger/file-tasks/projects — all projects
|
||||
router.get("/projects", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const { stdout } = await execInSandbox("cat /home/node/.openclaw/workspace/PROJECTS.md");
|
||||
const projects = parseProjectsMarkdown(stdout);
|
||||
res.json({ ok: true, source: "PROJECTS.md", count: projects.length, projects });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Loading…
Add table
Reference in a new issue