feat(bridge): TASKS.md inbox drainer + telegram transcript mirror route
- lib/inbox.ts: every 30min (09-20 IST) dispatch the first '- [ ]' item under '## INBOX' in TASKS.md: classifyAgent -> spawnTask -> rewrite the line with the run id. Bridge-side scheduling: no model tokens burned on empty checks, no bearer token embedded in cron prompts. Manual trigger: POST /tiger/inbox/drain - routes/chat-telegram.ts: GET /tiger/chat/telegram reads OpenClaw's native telegram session transcript (JSONL) with cursor pagination and mtime caching. Replaces the webhook/chat-mirror design, which could never work: the bot is owned by OpenClaw's long-polling channel, and Telegram forbids webhook + getUpdates on one token, so chat_messages never saw a row. - index.ts: wire both + start inbox scheduler
This commit is contained in:
parent
1a8358bb6a
commit
438488c8c8
3 changed files with 338 additions and 0 deletions
|
|
@ -154,7 +154,18 @@ 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/chat/mirror", (await import("./routes/chat-mirror.js")).default);
|
||||||
|
// Telegram mirror v2 — reads OpenClaw's native session transcript directly.
|
||||||
|
// (chat-mirror + telegram-webhook above are the legacy write-side, kept for
|
||||||
|
// API compatibility but no longer the data source for the dashboard card.)
|
||||||
|
app.use("/tiger/chat/telegram", (await import("./routes/chat-telegram.js")).default);
|
||||||
app.use("/tiger/telegram-webhook", (await import("./routes/telegram-webhook.js")).default);
|
app.use("/tiger/telegram-webhook", (await import("./routes/telegram-webhook.js")).default);
|
||||||
|
|
||||||
|
// TASKS.md inbox — manual drain trigger (the scheduler below runs it on its own)
|
||||||
|
const { drainInboxOnce, startInboxScheduler } = await import("./lib/inbox.js");
|
||||||
|
app.post("/tiger/inbox/drain", async (_req, res) => {
|
||||||
|
const result = await drainInboxOnce(true);
|
||||||
|
res.json({ ok: !result.startsWith("error"), result });
|
||||||
|
});
|
||||||
app.use("/angel", (await import("./routes/angel/positions.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
|
||||||
|
|
@ -186,6 +197,10 @@ app.listen(PORT, HOST, () => {
|
||||||
// Initialize file watcher for task status updates
|
// Initialize file watcher for task status updates
|
||||||
initWatcher();
|
initWatcher();
|
||||||
|
|
||||||
|
// TASKS.md inbox drainer — dispatches one pending item per cycle to a
|
||||||
|
// spawned specialist. See lib/inbox.ts for the contract.
|
||||||
|
startInboxScheduler();
|
||||||
|
|
||||||
// Start Telegram channel — bridge takes over from OpenClaw native handler.
|
// Start Telegram channel — bridge takes over from OpenClaw native handler.
|
||||||
// Requires channels.telegram.enabled=false in openclaw.json.
|
// Requires channels.telegram.enabled=false in openclaw.json.
|
||||||
const tgChannel = new TelegramChannel();
|
const tgChannel = new TelegramChannel();
|
||||||
|
|
|
||||||
151
bridge/src/lib/inbox.ts
Normal file
151
bridge/src/lib/inbox.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
/**
|
||||||
|
* lib/inbox.ts — TASKS.md as Tiger's inbox, drained by the bridge
|
||||||
|
*
|
||||||
|
* The productivity loop this enables:
|
||||||
|
* You drop a one-line task into the `## 📥 INBOX` section of TASKS.md
|
||||||
|
* (from Telegram via Tiger, from the dashboard workspace editor, or by
|
||||||
|
* hand). Every DRAIN_INTERVAL the bridge picks the FIRST unchecked item,
|
||||||
|
* asks classifyAgent() which specialist owns it, spawns that specialist
|
||||||
|
* (lib/agents.ts + routes/spawn.ts), and rewrites the line in place with
|
||||||
|
* the run id so nothing is picked twice. Completion is reported to
|
||||||
|
* Telegram by the spawn runner.
|
||||||
|
*
|
||||||
|
* Why the BRIDGE schedules this instead of an OpenClaw cron:
|
||||||
|
* - an OpenClaw cron job is itself an agent turn → it would burn a model
|
||||||
|
* call just to decide whether there is work, every hour
|
||||||
|
* - the cron prompt would need the bridge bearer token embedded in it
|
||||||
|
* (a secret inside a prompt — bad pattern)
|
||||||
|
* - the bridge can check TASKS.md for free and only spend model tokens
|
||||||
|
* (one classify call) when there is actually an item to dispatch
|
||||||
|
* The existing "Hourly Task Check-in" cron stays — it is Tiger's
|
||||||
|
* *narrative* status report; this is the *mechanical* dispatcher.
|
||||||
|
*
|
||||||
|
* INBOX line contract (inside TASKS.md):
|
||||||
|
* - [ ] research BESS tender pipeline in Gujarat ← pending
|
||||||
|
* - [⏳ exec_ab12cd → ethan] research BESS tender ... ← dispatched
|
||||||
|
* The drainer only ever touches `- [ ]` lines, one per cycle.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writeFileSync, unlinkSync } from "fs";
|
||||||
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import { classifyAgent } from "./llm.js";
|
||||||
|
import { spawnTask } from "../routes/spawn.js";
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
const DOCKER_CONTAINER = "tiger-openclaw";
|
||||||
|
const TASKS_PATH = "/home/node/.openclaw/workspace/TASKS.md";
|
||||||
|
const INBOX_HEADER = "## 📥 INBOX";
|
||||||
|
/** Check every 30 minutes, only act inside working hours (IST). */
|
||||||
|
const DRAIN_INTERVAL_MS = 30 * 60 * 1000;
|
||||||
|
const WORK_HOURS_IST = { start: 9, end: 20 };
|
||||||
|
|
||||||
|
const PENDING_LINE = /^- \[ \] (.+)$/;
|
||||||
|
|
||||||
|
let draining = false;
|
||||||
|
|
||||||
|
function istHour(): number {
|
||||||
|
return Number(
|
||||||
|
new Intl.DateTimeFormat("en-GB", {
|
||||||
|
timeZone: "Asia/Kolkata",
|
||||||
|
hour: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
}).format(new Date()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readTasksFile(): Promise<string> {
|
||||||
|
const { stdout } = await execAsync(
|
||||||
|
`docker exec ${DOCKER_CONTAINER} cat ${TASKS_PATH}`,
|
||||||
|
{ timeout: 10_000, maxBuffer: 1024 * 1024 },
|
||||||
|
);
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeTasksFile(content: string): Promise<void> {
|
||||||
|
// docker cp (same escaping-proof transport as spawn/telegram message passing)
|
||||||
|
const tmp = `/tmp/tasks_inbox_${Date.now()}.md`;
|
||||||
|
writeFileSync(tmp, content, "utf-8");
|
||||||
|
try {
|
||||||
|
await execAsync(`docker cp ${tmp} ${DOCKER_CONTAINER}:${TASKS_PATH}`, {
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
unlinkSync(tmp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One drain cycle: dispatch at most ONE pending inbox item.
|
||||||
|
* Exported so routes can trigger it manually (POST /tiger/inbox/drain).
|
||||||
|
* Returns a human-readable outcome for logs/API.
|
||||||
|
*/
|
||||||
|
export async function drainInboxOnce(force = false): Promise<string> {
|
||||||
|
if (draining) return "skipped: drain already in progress";
|
||||||
|
const hour = istHour();
|
||||||
|
if (!force && (hour < WORK_HOURS_IST.start || hour >= WORK_HOURS_IST.end)) {
|
||||||
|
return `skipped: outside work hours (IST hour ${hour})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
draining = true;
|
||||||
|
try {
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = await readTasksFile();
|
||||||
|
} catch {
|
||||||
|
return "skipped: TASKS.md not readable";
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = content.split("\n");
|
||||||
|
const headerIdx = lines.findIndex((l) => l.trim().startsWith(INBOX_HEADER));
|
||||||
|
if (headerIdx === -1) return "skipped: no INBOX section in TASKS.md";
|
||||||
|
|
||||||
|
// Scan from the header to the next section header (or EOF).
|
||||||
|
let target = -1;
|
||||||
|
let taskText = "";
|
||||||
|
for (let i = headerIdx + 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (line.startsWith("## ")) break; // next section — inbox ended
|
||||||
|
const m = line.match(PENDING_LINE);
|
||||||
|
if (m) {
|
||||||
|
target = i;
|
||||||
|
taskText = m[1].trim();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (target === -1) return "ok: inbox empty";
|
||||||
|
|
||||||
|
// Route → spawn → mark, in that order. If classify fails (e.g. the LLM
|
||||||
|
// gateway is down) we leave the line untouched and retry next cycle.
|
||||||
|
const { agent: agentId, reason } = await classifyAgent(taskText);
|
||||||
|
const spawnable = agentId === "tiger" ? "elon" : agentId; // orchestrator work → PM
|
||||||
|
const ticket = spawnTask({ agentId: spawnable, task: taskText });
|
||||||
|
|
||||||
|
lines[target] = `- [⏳ ${ticket.runId} → ${ticket.agent.id}] ${taskText}`;
|
||||||
|
await writeTasksFile(lines.join("\n"));
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[inbox] dispatched "${taskText.slice(0, 60)}" → ${ticket.agent.id} ` +
|
||||||
|
`(${ticket.runId}; classifier said ${agentId}: ${reason.slice(0, 80)})`,
|
||||||
|
);
|
||||||
|
return `dispatched: ${ticket.runId} → ${ticket.agent.id}`;
|
||||||
|
} catch (err) {
|
||||||
|
const m = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error("[inbox] drain failed:", m);
|
||||||
|
return `error: ${m}`;
|
||||||
|
} finally {
|
||||||
|
draining = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call once from index.ts at startup. */
|
||||||
|
export function startInboxScheduler(): void {
|
||||||
|
setInterval(() => {
|
||||||
|
void drainInboxOnce();
|
||||||
|
}, DRAIN_INTERVAL_MS);
|
||||||
|
console.log(
|
||||||
|
`[inbox] scheduler started — every ${DRAIN_INTERVAL_MS / 60000}min, ` +
|
||||||
|
`${WORK_HOURS_IST.start}:00–${WORK_HOURS_IST.end}:00 IST`,
|
||||||
|
);
|
||||||
|
}
|
||||||
172
bridge/src/routes/chat-telegram.ts
Normal file
172
bridge/src/routes/chat-telegram.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
/**
|
||||||
|
* chat-telegram.ts — GET /tiger/chat/telegram : the REAL Telegram mirror
|
||||||
|
*
|
||||||
|
* History of this feature (why the old one showed nothing):
|
||||||
|
* The original design (telegram-webhook.ts + chat-mirror.ts) waited for
|
||||||
|
* Telegram to POST updates to the bridge. But the bot is handled by
|
||||||
|
* OpenClaw's NATIVE telegram channel via long-polling, and Telegram's API
|
||||||
|
* forbids webhook + getUpdates on the same bot token — so the webhook was
|
||||||
|
* never registered, chat_messages never received a single Telegram row,
|
||||||
|
* and the dashboard card stayed empty. Even if it had worked, it could
|
||||||
|
* only see inbound messages, never Tiger's replies.
|
||||||
|
*
|
||||||
|
* This route reads the conversation from the source of truth instead:
|
||||||
|
* OpenClaw's session transcript (JSONL) for the telegram:direct session.
|
||||||
|
* It is the same file Tiger's own context is built from, so the dashboard
|
||||||
|
* is in perfect sync by construction — both directions, full history,
|
||||||
|
* nothing to register, nothing to double-write.
|
||||||
|
*
|
||||||
|
* GET /tiger/chat/telegram?limit=50&before=<seq>
|
||||||
|
* → { ok, sessionKey, messages: [{ seq, role, text, timestamp }], hasMore, oldestSeq }
|
||||||
|
* - messages are ascending by seq (chronological)
|
||||||
|
* - `before` pages backwards through history (omit for the newest page)
|
||||||
|
*
|
||||||
|
* Transcript line shape (verified live on tiger-config volume):
|
||||||
|
* { type:"message", timestamp:"2026-06-09T21:08:38.574Z",
|
||||||
|
* message:{ role:"user"|"assistant"|"toolResult", content:[{type:"text",text}] } }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
import { readFileSync, statSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// The bridge runs on the host as root, so it reads the docker volume directly —
|
||||||
|
// no docker-exec hop on a route that the dashboard polls every few seconds.
|
||||||
|
const DATA_DIR =
|
||||||
|
process.env.OPENCLAW_DATA_DIR ||
|
||||||
|
"/var/lib/docker/volumes/tiger_tiger-config/_data";
|
||||||
|
const SESSIONS_DIR = join(DATA_DIR, "agents", "main", "sessions");
|
||||||
|
|
||||||
|
interface ThreadMessage {
|
||||||
|
seq: number;
|
||||||
|
role: "user" | "agent";
|
||||||
|
text: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionIndexEntry {
|
||||||
|
sessionId?: string;
|
||||||
|
updatedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Newest telegram session: key + transcript path. */
|
||||||
|
function resolveTelegramSession(): { key: string; file: string } | null {
|
||||||
|
let index: Record<string, SessionIndexEntry>;
|
||||||
|
try {
|
||||||
|
index = JSON.parse(
|
||||||
|
readFileSync(join(SESSIONS_DIR, "sessions.json"), "utf-8"),
|
||||||
|
) as Record<string, SessionIndexEntry>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = Object.entries(index)
|
||||||
|
.filter(([key]) => key.includes(":telegram:") || key.includes(":tg_"))
|
||||||
|
.sort((a, b) => (b[1].updatedAt ?? 0) - (a[1].updatedAt ?? 0));
|
||||||
|
|
||||||
|
for (const [key, entry] of candidates) {
|
||||||
|
if (entry.sessionId) {
|
||||||
|
return { key, file: join(SESSIONS_DIR, `${entry.sessionId}.jsonl`) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Parse cache ─────────────────────────────────────────────────────────────
|
||||||
|
// The card polls for new messages; re-parsing the whole transcript each poll
|
||||||
|
// is wasted work. Cache the parsed array keyed on (path, mtime, size).
|
||||||
|
|
||||||
|
let cache: { file: string; mtimeMs: number; size: number; messages: ThreadMessage[] } | null = null;
|
||||||
|
|
||||||
|
function parseTranscript(file: string): ThreadMessage[] {
|
||||||
|
const st = statSync(file);
|
||||||
|
if (
|
||||||
|
cache &&
|
||||||
|
cache.file === file &&
|
||||||
|
cache.mtimeMs === st.mtimeMs &&
|
||||||
|
cache.size === st.size
|
||||||
|
) {
|
||||||
|
return cache.messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages: ThreadMessage[] = [];
|
||||||
|
const lines = readFileSync(file, "utf-8").split("\n");
|
||||||
|
let seq = 0;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
seq += 1;
|
||||||
|
let entry: Record<string, any>;
|
||||||
|
try {
|
||||||
|
entry = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.type !== "message") continue;
|
||||||
|
|
||||||
|
const role = entry.message?.role;
|
||||||
|
if (role !== "user" && role !== "assistant") continue; // skip toolResult etc.
|
||||||
|
|
||||||
|
const content: unknown = entry.message?.content;
|
||||||
|
let text = "";
|
||||||
|
if (typeof content === "string") {
|
||||||
|
text = content;
|
||||||
|
} else if (Array.isArray(content)) {
|
||||||
|
text = content
|
||||||
|
.filter((c) => c && c.type === "text" && typeof c.text === "string")
|
||||||
|
.map((c) => c.text)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
text = text.trim();
|
||||||
|
if (!text) continue; // tool-call-only assistant turns have no text
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
seq,
|
||||||
|
role: role === "user" ? "user" : "agent",
|
||||||
|
text,
|
||||||
|
timestamp: typeof entry.timestamp === "string" ? entry.timestamp : "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cache = { file, mtimeMs: st.mtimeMs, size: st.size, messages };
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/", (req: Request, res: Response) => {
|
||||||
|
const limit = Math.min(
|
||||||
|
Math.max(parseInt(String(req.query.limit ?? "50"), 10) || 50, 1),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
const before = parseInt(String(req.query.before ?? ""), 10) || null;
|
||||||
|
|
||||||
|
const session = resolveTelegramSession();
|
||||||
|
if (!session) {
|
||||||
|
return res.status(404).json({
|
||||||
|
ok: false,
|
||||||
|
error: "No telegram session found in OpenClaw session index",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let all: ThreadMessage[];
|
||||||
|
try {
|
||||||
|
all = parseTranscript(session.file);
|
||||||
|
} catch (err) {
|
||||||
|
const m = err instanceof Error ? err.message : String(err);
|
||||||
|
return res.status(500).json({ ok: false, error: `transcript read failed: ${m}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const upTo = before === null ? all : all.filter((m) => m.seq < before);
|
||||||
|
const page = upTo.slice(-limit);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
sessionKey: session.key,
|
||||||
|
messages: page,
|
||||||
|
hasMore: upTo.length > page.length,
|
||||||
|
oldestSeq: page.length > 0 ? page[0].seq : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
Loading…
Add table
Reference in a new issue