- /api/cron/reminders: processes pending reminders every 15min, sends WhatsApp with email fallback - /api/cron/overdue: marks overdue pledges daily (7d deferred, 14d immediate) - /api/pledges: GET handler with filtering, search, pagination, sort by dueDate - Dashboard overview: stats, collection progress bar, needs attention, upcoming payments - Dashboard pledges: proper table with status tabs, search, actions, pagination - New shadcn components: Table, Tabs, DropdownMenu, Progress - Setup wizard: 4-step onboarding (org → bank → event → QR code) - Settings API: PUT handler for org create/update - Org resolver: single-tenant fallback to first org - Cron jobs installed: reminders every 15min, overdue check at 6am - Auto-generates installment dates when not provided - HOSTNAME=0.0.0.0 in compose for multi-network binding
342 lines
10 KiB
TypeScript
342 lines
10 KiB
TypeScript
import * as tg from "./telegram.js";
|
|
import * as store from "./store.js";
|
|
import { runAgent, isWaitingForUser, resolveUserResponse } from "./agent-worker.js";
|
|
|
|
// Track which agents are running (in-process)
|
|
const runningAgents = new Set<number>();
|
|
|
|
// Track "talk mode" — chatId -> agentId they're talking to
|
|
const talkMode = new Map<number, number>();
|
|
|
|
const STATUS_EMOJI: Record<string, string> = {
|
|
spawning: "🔄",
|
|
working: "⚡",
|
|
waiting: "❓",
|
|
done: "✅",
|
|
error: "❌",
|
|
killed: "🛑",
|
|
};
|
|
|
|
async function handleMessage(msg: NonNullable<tg.TelegramUpdate["message"]>) {
|
|
const chatId = msg.chat.id;
|
|
const text = (msg.text || "").trim();
|
|
|
|
if (!tg.isAllowed(chatId)) return;
|
|
|
|
// Check if user is in talk mode with an agent
|
|
if (talkMode.has(chatId) && !text.startsWith("/")) {
|
|
const agentId = talkMode.get(chatId)!;
|
|
talkMode.delete(chatId);
|
|
|
|
if (isWaitingForUser(agentId)) {
|
|
resolveUserResponse(agentId, text);
|
|
await tg.send(`💬 Sent to Agent #${agentId}`, chatId);
|
|
} else {
|
|
// Agent is working but user wants to interject — add as follow-up
|
|
// For now just queue it
|
|
store.addLog(agentId, "user", text);
|
|
await tg.send(
|
|
`📝 Noted for Agent #${agentId}. It's currently working — your message will be seen when it next checks.`,
|
|
chatId
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Check if this is a reply to an agent thread
|
|
if (msg.reply_to_message && !text.startsWith("/")) {
|
|
const replyToId = msg.reply_to_message.message_id;
|
|
const agent = store.findAgentByThreadMsg(replyToId);
|
|
if (agent && isWaitingForUser(agent.id)) {
|
|
resolveUserResponse(agent.id, text);
|
|
await tg.send(`💬 Sent to Agent #${agent.id}`, chatId);
|
|
return;
|
|
}
|
|
// Also check all agents for this chat — find closest thread
|
|
const agents = store.listAgents(String(chatId));
|
|
for (const a of agents) {
|
|
if (a.thread_msg_id === replyToId && isWaitingForUser(a.id)) {
|
|
resolveUserResponse(a.id, text);
|
|
await tg.send(`💬 Sent to Agent #${a.id}`, chatId);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Commands
|
|
if (text.startsWith("/new ") || text.startsWith("/new@")) {
|
|
const task = text.replace(/^\/new(@\w+)?\s*/, "").trim();
|
|
if (!task) {
|
|
await tg.send("Usage: `/new <task description>`", chatId);
|
|
return;
|
|
}
|
|
await spawnAgent(chatId, task);
|
|
return;
|
|
}
|
|
|
|
if (text === "/board" || text.startsWith("/board@")) {
|
|
await showBoard(chatId);
|
|
return;
|
|
}
|
|
|
|
if (text.startsWith("/kill ") || text.startsWith("/kill@")) {
|
|
const idStr = text.replace(/^\/kill(@\w+)?\s*/, "").trim();
|
|
const id = parseInt(idStr);
|
|
if (isNaN(id)) {
|
|
await tg.send("Usage: `/kill <agent_id>`", chatId);
|
|
return;
|
|
}
|
|
await killAgent(chatId, id);
|
|
return;
|
|
}
|
|
|
|
if (text.startsWith("/logs ") || text.startsWith("/logs@")) {
|
|
const idStr = text.replace(/^\/logs(@\w+)?\s*/, "").trim();
|
|
const id = parseInt(idStr);
|
|
if (isNaN(id)) {
|
|
await tg.send("Usage: `/logs <agent_id>`", chatId);
|
|
return;
|
|
}
|
|
await showLogs(chatId, id);
|
|
return;
|
|
}
|
|
|
|
if (text.startsWith("/talk ") || text.startsWith("/talk@")) {
|
|
const idStr = text.replace(/^\/talk(@\w+)?\s*/, "").trim();
|
|
const id = parseInt(idStr);
|
|
if (isNaN(id)) {
|
|
await tg.send("Usage: `/talk <agent_id>`", chatId);
|
|
return;
|
|
}
|
|
talkMode.set(chatId, id);
|
|
await tg.send(
|
|
`💬 You're now talking to *Agent #${id}*. Send your message (or /cancel):`,
|
|
chatId
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (text === "/cancel" || text.startsWith("/cancel@")) {
|
|
if (talkMode.has(chatId)) {
|
|
talkMode.delete(chatId);
|
|
await tg.send("Cancelled talk mode.", chatId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (text === "/clear" || text.startsWith("/clear@")) {
|
|
await clearChat(chatId, msg.message_id);
|
|
return;
|
|
}
|
|
|
|
if (text === "/help" || text === "/start" || text.startsWith("/help@") || text.startsWith("/start@")) {
|
|
await tg.send(
|
|
`🤖 *Agent Orchestrator*
|
|
|
|
Commands:
|
|
/new <task> — Spawn a new agent
|
|
/board — View all agents
|
|
/logs <id> — Agent activity log
|
|
/talk <id> — Send message to agent
|
|
/kill <id> — Stop an agent
|
|
/clear — Clear chat messages
|
|
/help — This message
|
|
|
|
Or *reply* to any agent message to talk to it directly.`,
|
|
chatId
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Unknown
|
|
if (text.startsWith("/")) {
|
|
await tg.send("Unknown command. Try /help", chatId);
|
|
}
|
|
}
|
|
|
|
async function handleCallback(cb: NonNullable<tg.TelegramUpdate["callback_query"]>) {
|
|
const chatId = cb.message?.chat?.id;
|
|
if (!chatId || !tg.isAllowed(chatId)) return;
|
|
|
|
const data = cb.data || "";
|
|
await tg.answerCallback(cb.id);
|
|
|
|
if (data.startsWith("logs_")) {
|
|
const id = parseInt(data.replace("logs_", ""));
|
|
await showLogs(chatId, id);
|
|
} else if (data.startsWith("talk_")) {
|
|
const id = parseInt(data.replace("talk_", ""));
|
|
talkMode.set(chatId, id);
|
|
await tg.send(
|
|
`💬 Talking to *Agent #${id}*. Send your message:`,
|
|
chatId
|
|
);
|
|
} else if (data.startsWith("kill_")) {
|
|
const id = parseInt(data.replace("kill_", ""));
|
|
await killAgent(chatId, id);
|
|
}
|
|
}
|
|
|
|
async function spawnAgent(chatId: number, task: string) {
|
|
const agent = store.createAgent(task, String(chatId));
|
|
|
|
// Send initial message and save its ID for threading
|
|
const res = await tg.send(
|
|
`🤖 *Agent #${agent.id}* spawned\n*Task:* ${task}\n*Status:* spawning...`,
|
|
chatId,
|
|
{ keyboard: tg.agentKeyboard(agent.id) }
|
|
);
|
|
|
|
if (res?.result?.message_id) {
|
|
store.updateAgent(agent.id, { thread_msg_id: res.result.message_id });
|
|
}
|
|
|
|
// Run agent in background (non-blocking)
|
|
runningAgents.add(agent.id);
|
|
runAgent(agent.id)
|
|
.catch((e) => console.error(`[agent ${agent.id}] fatal:`, e))
|
|
.finally(() => runningAgents.delete(agent.id));
|
|
}
|
|
|
|
async function showBoard(chatId: number) {
|
|
const agents = store.listAgents(String(chatId));
|
|
|
|
if (agents.length === 0) {
|
|
await tg.send("No agents yet. Use `/new <task>` to spawn one.", chatId);
|
|
return;
|
|
}
|
|
|
|
let board = "📋 *Agent Board*\n\n";
|
|
board += "```\n";
|
|
board += " # | Status | Task\n";
|
|
board += "----|--------|---------------------------\n";
|
|
|
|
for (const a of agents.slice(0, 20)) {
|
|
const emoji = STATUS_EMOJI[a.status] || "❔";
|
|
const taskShort = a.task.length > 30 ? a.task.slice(0, 27) + "..." : a.task;
|
|
board += ` ${String(a.id).padStart(2)} | ${emoji} ${a.status.padEnd(4).slice(0, 4)} | ${taskShort}\n`;
|
|
}
|
|
board += "```\n";
|
|
|
|
// Show summaries for done agents
|
|
const done = agents.filter((a) => a.summary);
|
|
if (done.length > 0) {
|
|
board += "\n*Completed:*\n";
|
|
for (const a of done.slice(0, 5)) {
|
|
board += `• #${a.id}: ${a.summary}\n`;
|
|
}
|
|
}
|
|
|
|
await tg.send(board, chatId);
|
|
}
|
|
|
|
async function killAgent(chatId: number, agentId: number) {
|
|
const agent = store.getAgent(agentId);
|
|
if (!agent) {
|
|
await tg.send(`Agent #${agentId} not found.`, chatId);
|
|
return;
|
|
}
|
|
if (agent.chat_id !== String(chatId)) {
|
|
await tg.send(`Agent #${agentId} doesn't belong to you.`, chatId);
|
|
return;
|
|
}
|
|
if (agent.status === "done" || agent.status === "killed") {
|
|
await tg.send(`Agent #${agentId} is already ${agent.status}.`, chatId);
|
|
return;
|
|
}
|
|
|
|
store.updateAgent(agentId, { status: "killed", summary: "Killed by user" });
|
|
store.addLog(agentId, "system", "Killed by user");
|
|
|
|
// If waiting for user, resolve with cancellation
|
|
if (isWaitingForUser(agentId)) {
|
|
resolveUserResponse(agentId, "[USER CANCELLED THIS AGENT]");
|
|
}
|
|
|
|
await tg.send(`🛑 Agent #${agentId} killed.`, chatId);
|
|
}
|
|
|
|
async function showLogs(chatId: number, agentId: number) {
|
|
const agent = store.getAgent(agentId);
|
|
if (!agent) {
|
|
await tg.send(`Agent #${agentId} not found.`, chatId);
|
|
return;
|
|
}
|
|
|
|
const logs = store.getLogs(agentId, 15);
|
|
if (logs.length === 0) {
|
|
await tg.send(`No logs for Agent #${agentId}.`, chatId);
|
|
return;
|
|
}
|
|
|
|
let text = `📋 *Logs — Agent #${agentId}*\n_${agent.task}_\n\n`;
|
|
for (const log of logs.reverse()) {
|
|
const role = log.role.toUpperCase().padEnd(6).slice(0, 6);
|
|
const content = log.content.length > 150 ? log.content.slice(0, 147) + "..." : log.content;
|
|
text += `\`${role}\` ${content}\n\n`;
|
|
}
|
|
|
|
await tg.send(text, chatId, { keyboard: tg.agentKeyboard(agentId) });
|
|
}
|
|
|
|
async function clearChat(chatId: number, commandMsgId: number) {
|
|
// Delete the /clear command message itself first
|
|
await tg.deleteMessage(chatId, commandMsgId);
|
|
|
|
// Telegram only allows deleting messages less than 48h old.
|
|
// We walk backwards from the command message ID, trying to delete recent messages.
|
|
const statusMsg = await tg.send("🧹 Clearing chat...", chatId);
|
|
const statusMsgId: number | undefined = statusMsg?.result?.message_id;
|
|
|
|
let deleted = 0;
|
|
let misses = 0;
|
|
const MAX_MISSES = 10; // stop after 10 consecutive failures (hit old messages or gap)
|
|
|
|
// Walk backwards from the /clear message
|
|
for (let id = commandMsgId - 1; id > 0 && misses < MAX_MISSES; id--) {
|
|
const ok = await tg.deleteMessage(chatId, id);
|
|
if (ok) {
|
|
deleted++;
|
|
misses = 0;
|
|
} else {
|
|
misses++;
|
|
}
|
|
}
|
|
|
|
// Delete the status message too, then send a clean confirmation
|
|
if (statusMsgId) await tg.deleteMessage(chatId, statusMsgId);
|
|
await tg.send(`🧹 Cleared ${deleted} messages.`, chatId);
|
|
}
|
|
|
|
// --- Main loop ---
|
|
|
|
async function main() {
|
|
console.log("🤖 Telegram Agent Orchestrator starting...");
|
|
console.log(` Polling for updates...`);
|
|
|
|
await tg.send("🤖 Agent Orchestrator is online! Send /help to get started.");
|
|
|
|
while (true) {
|
|
try {
|
|
const updates = await tg.poll();
|
|
for (const update of updates) {
|
|
if (update.message) {
|
|
handleMessage(update.message).catch((e) =>
|
|
console.error("[handle msg]", e)
|
|
);
|
|
}
|
|
if (update.callback_query) {
|
|
handleCallback(update.callback_query).catch((e) =>
|
|
console.error("[handle cb]", e)
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error("[main loop]", e);
|
|
await new Promise((r) => setTimeout(r, 5000));
|
|
}
|
|
}
|
|
}
|
|
|
|
main();
|