production: reminder cron, dashboard overhaul, shadcn components, setup wizard

- /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
This commit is contained in:
2026-03-03 05:11:17 +08:00
parent 250221b530
commit c79b9bcabc
61 changed files with 3547 additions and 534 deletions

341
lib/bot.ts Normal file
View File

@@ -0,0 +1,341 @@
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();