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

198
lib/agent-worker.ts Normal file
View File

@@ -0,0 +1,198 @@
import Anthropic from "@anthropic-ai/sdk";
import { toolDefs, executeTool } from "./tools.js";
import * as store from "./store.js";
import * as tg from "./telegram.js";
const client = new Anthropic();
const MODEL = "claude-sonnet-4-20250514";
const MAX_TURNS = 50;
// Map of agentId -> resolve function for when user replies
const waitingForUser = new Map<number, (response: string) => void>();
export function isWaitingForUser(agentId: number): boolean {
return waitingForUser.has(agentId);
}
export function resolveUserResponse(agentId: number, response: string): void {
const resolve = waitingForUser.get(agentId);
if (resolve) {
waitingForUser.delete(agentId);
resolve(response);
}
}
function buildSystemPrompt(task: string): string {
return `You are an autonomous coding agent. You have been assigned a specific task.
YOUR TASK:
${task}
GUIDELINES:
- Work independently to complete the task
- Use the bash tool for running commands, git, etc.
- Use read_file, write_file, edit_file for file operations
- When you're done, call the "done" tool with a summary
- If you need user input or a decision, call "ask_user"
- Be efficient — don't explain what you're about to do, just do it
- If something fails, try to fix it yourself before asking the user
WORKING DIRECTORY: ${process.cwd()}`;
}
export async function runAgent(agentId: number): Promise<void> {
const agent = store.getAgent(agentId);
if (!agent) return;
store.updateAgent(agentId, { status: "working" });
store.addLog(agentId, "system", `Agent started: ${agent.task}`);
const messages: Anthropic.MessageParam[] = [
{ role: "user", content: agent.task },
];
let turns = 0;
try {
while (turns < MAX_TURNS) {
turns++;
const response = await client.messages.create({
model: MODEL,
max_tokens: 8096,
system: buildSystemPrompt(agent.task),
tools: toolDefs as any,
messages,
});
// Collect assistant content
const assistantContent = response.content;
messages.push({ role: "assistant", content: assistantContent });
// Log text blocks (no Telegram notification — reduces noise)
for (const block of assistantContent) {
if (block.type === "text" && block.text.trim()) {
store.addLog(agentId, "assistant", block.text);
}
}
// If no tool use, we're done
if (response.stop_reason !== "tool_use") {
store.updateAgent(agentId, {
status: "done",
summary: "Completed (no more actions)",
});
store.addLog(agentId, "system", "Agent finished (end_turn)");
await tg.send(
`✅ *Agent #${agentId}* finished.\nTask: ${agent.task}`,
agent.chat_id,
{ reply_to: agent.thread_msg_id || undefined }
);
return;
}
// Process tool calls
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of assistantContent) {
if (block.type !== "tool_use") continue;
const toolName = block.name;
const toolInput = block.input as Record<string, unknown>;
store.addLog(
agentId,
"tool",
`${toolName}: ${JSON.stringify(toolInput).slice(0, 500)}`
);
if (toolName === "ask_user") {
// Pause and wait for user response
store.updateAgent(agentId, { status: "waiting" });
await tg.send(
`❓ *Agent #${agentId}* needs your input:\n\n${toolInput.question}`,
agent.chat_id,
{
reply_to: agent.thread_msg_id || undefined,
keyboard: [
[{ text: "💬 Reply", callback_data: `talk_${agentId}` }],
],
}
);
store.addLog(agentId, "system", `Waiting for user: ${toolInput.question}`);
// Wait for user response
const userResponse = await new Promise<string>((resolve) => {
waitingForUser.set(agentId, resolve);
});
store.updateAgent(agentId, { status: "working" });
store.addLog(agentId, "user", `User replied: ${userResponse}`);
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: `User responded: ${userResponse}`,
});
continue;
}
if (toolName === "done") {
const summary = (toolInput.summary as string) || "Task completed";
store.updateAgent(agentId, { status: "done", summary });
store.addLog(agentId, "system", `Done: ${summary}`);
await tg.send(
`✅ *Agent #${agentId}* completed!\n\n*Summary:* ${summary}\n*Task:* ${agent.task}`,
agent.chat_id,
{ reply_to: agent.thread_msg_id || undefined }
);
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: summary,
});
// Push tool results and stop
messages.push({ role: "user", content: toolResults });
return;
}
// Execute the tool
const { result } = executeTool(toolName, toolInput);
store.addLog(
agentId,
"tool_result",
`${toolName}${result.slice(0, 500)}`
);
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: result,
});
}
messages.push({ role: "user", content: toolResults });
}
// Hit max turns
store.updateAgent(agentId, {
status: "done",
summary: `Stopped after ${MAX_TURNS} turns`,
});
await tg.send(
`⚠️ *Agent #${agentId}* hit max turns (${MAX_TURNS}). Task: ${agent.task}`,
agent.chat_id,
{ reply_to: agent.thread_msg_id || undefined }
);
} catch (e: any) {
console.error(`[agent ${agentId}] error:`, e);
store.updateAgent(agentId, { status: "error", error: e.message });
store.addLog(agentId, "error", e.message);
await tg.send(
`❌ *Agent #${agentId}* error:\n${e.message?.slice(0, 500)}`,
agent.chat_id,
{ reply_to: agent.thread_msg_id || undefined }
);
}
}

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();

179
lib/store.ts Normal file
View File

@@ -0,0 +1,179 @@
import { Database } from "bun:sqlite";
import { mkdirSync } from "fs";
mkdirSync("data", { recursive: true });
const db = new Database("data/agents.db");
db.run("PRAGMA journal_mode = WAL");
db.run(`
CREATE TABLE IF NOT EXISTS agents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'spawning',
chat_id TEXT NOT NULL,
thread_msg_id INTEGER,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
summary TEXT,
error TEXT
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS agent_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (agent_id) REFERENCES agents(id)
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS agent_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
tool_use TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (agent_id) REFERENCES agents(id)
)
`);
export interface Agent {
id: number;
task: string;
status: string;
chat_id: string;
thread_msg_id: number | null;
created_at: string;
updated_at: string;
summary: string | null;
error: string | null;
}
export interface AgentLog {
id: number;
agent_id: number;
role: string;
content: string;
created_at: string;
}
// --- Agent CRUD ---
export function createAgent(task: string, chatId: string): Agent {
const stmt = db.prepare(
`INSERT INTO agents (task, status, chat_id) VALUES (?, 'spawning', ?)`
);
stmt.run(task, chatId);
const id = Number(db.query("SELECT last_insert_rowid() as id").get()!.id);
return getAgent(id)!;
}
export function getAgent(id: number): Agent | undefined {
return db.query(`SELECT * FROM agents WHERE id = ?`).get(id) as
| Agent
| undefined;
}
export function updateAgent(
id: number,
updates: Partial<Pick<Agent, "status" | "thread_msg_id" | "summary" | "error">>
): void {
const fields: string[] = ["updated_at = datetime('now')"];
const values: unknown[] = [];
if (updates.status !== undefined) {
fields.push("status = ?");
values.push(updates.status);
}
if (updates.thread_msg_id !== undefined) {
fields.push("thread_msg_id = ?");
values.push(updates.thread_msg_id);
}
if (updates.summary !== undefined) {
fields.push("summary = ?");
values.push(updates.summary);
}
if (updates.error !== undefined) {
fields.push("error = ?");
values.push(updates.error);
}
values.push(id);
db.prepare(`UPDATE agents SET ${fields.join(", ")} WHERE id = ?`).run(
...values
);
}
export function listAgents(chatId?: string): Agent[] {
if (chatId) {
return db
.query(`SELECT * FROM agents WHERE chat_id = ? ORDER BY id DESC`)
.all(chatId) as Agent[];
}
return db.query(`SELECT * FROM agents ORDER BY id DESC`).all() as Agent[];
}
export function getActiveAgents(): Agent[] {
return db
.query(
`SELECT * FROM agents WHERE status IN ('spawning', 'working', 'waiting') ORDER BY id`
)
.all() as Agent[];
}
export function findAgentByThreadMsg(messageId: number): Agent | undefined {
return db
.query(`SELECT * FROM agents WHERE thread_msg_id = ?`)
.get(messageId) as Agent | undefined;
}
// --- Logs ---
export function addLog(agentId: number, role: string, content: string): void {
db.prepare(
`INSERT INTO agent_logs (agent_id, role, content) VALUES (?, ?, ?)`
).run(agentId, role, content);
}
export function getLogs(agentId: number, limit = 20): AgentLog[] {
return db
.query(
`SELECT * FROM agent_logs WHERE agent_id = ? ORDER BY id DESC LIMIT ?`
)
.all(agentId, limit) as AgentLog[];
}
// --- Messages (conversation history) ---
export function addMessage(
agentId: number,
role: string,
content: string,
toolUse?: string
): void {
db.prepare(
`INSERT INTO agent_messages (agent_id, role, content, tool_use) VALUES (?, ?, ?, ?)`
).run(agentId, role, content, toolUse || null);
}
export function getMessages(
agentId: number
): Array<{ role: string; content: string; tool_use: string | null }> {
return db
.query(
`SELECT role, content, tool_use FROM agent_messages WHERE agent_id = ? ORDER BY id ASC`
)
.all(agentId) as Array<{
role: string;
content: string;
tool_use: string | null;
}>;
}
export default db;

142
lib/telegram.ts Normal file
View File

@@ -0,0 +1,142 @@
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN!;
const DEFAULT_CHAT_ID = process.env.TELEGRAM_CHAT_ID!;
const ALLOWED_IDS = new Set(
(process.env.TELEGRAM_ALLOWED_CHAT_IDS || DEFAULT_CHAT_ID)
.split(",")
.map((id) => id.trim())
);
const api = async (method: string, body?: Record<string, unknown>) => {
const res = await fetch(
`https://api.telegram.org/bot${BOT_TOKEN}/${method}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
}
);
return res.json();
};
export function isAllowed(chatId: number | string): boolean {
return ALLOWED_IDS.has(String(chatId));
}
export async function send(
text: string,
chatId: string | number = DEFAULT_CHAT_ID,
opts?: { reply_to?: number; keyboard?: InlineKeyboard }
): Promise<any> {
if (!isAllowed(chatId)) return null;
// Telegram limits messages to 4096 chars
const truncated =
text.length > 4000 ? text.slice(0, 4000) + "\n\n... (truncated)" : text;
const body: Record<string, unknown> = {
chat_id: chatId,
text: truncated,
parse_mode: "Markdown",
};
if (opts?.reply_to) body.reply_to_message_id = opts.reply_to;
if (opts?.keyboard) {
body.reply_markup = { inline_keyboard: opts.keyboard };
}
return api("sendMessage", body);
}
export async function editMessage(
chatId: string | number,
messageId: number,
text: string,
keyboard?: InlineKeyboard
): Promise<any> {
const truncated =
text.length > 4000 ? text.slice(0, 4000) + "\n\n... (truncated)" : text;
const body: Record<string, unknown> = {
chat_id: chatId,
message_id: messageId,
text: truncated,
parse_mode: "Markdown",
};
if (keyboard) body.reply_markup = { inline_keyboard: keyboard };
return api("editMessageText", body);
}
export async function answerCallback(
callbackId: string,
text?: string
): Promise<any> {
return api("answerCallbackQuery", {
callback_query_id: callbackId,
text,
});
}
export type InlineKeyboard = Array<
Array<{ text: string; callback_data: string }>
>;
export function agentKeyboard(agentId: number): InlineKeyboard {
return [
[
{ text: "📋 Logs", callback_data: `logs_${agentId}` },
{ text: "💬 Talk", callback_data: `talk_${agentId}` },
{ text: "🛑 Kill", callback_data: `kill_${agentId}` },
],
];
}
let offset = 0;
export interface TelegramUpdate {
update_id: number;
message?: {
message_id: number;
from?: { id: number; first_name: string };
chat: { id: number; type: string };
text?: string;
reply_to_message?: { message_id: number };
};
callback_query?: {
id: string;
from: { id: number };
message?: { message_id: number; chat: { id: number } };
data?: string;
};
}
export async function poll(): Promise<TelegramUpdate[]> {
try {
const data = await api("getUpdates", {
offset,
timeout: 30,
allowed_updates: ["message", "callback_query"],
});
const updates: TelegramUpdate[] = data.result || [];
if (updates.length > 0) {
offset = updates[updates.length - 1].update_id + 1;
}
return updates;
} catch (e) {
console.error("[telegram] poll error:", e);
return [];
}
}
export async function deleteMessage(
chatId: string | number,
messageId: number
): Promise<boolean> {
try {
const res = await api("deleteMessage", {
chat_id: chatId,
message_id: messageId,
});
return !!res?.ok;
} catch {
return false;
}
}
export default { send, editMessage, deleteMessage, poll, isAllowed, agentKeyboard, answerCallback };

194
lib/tools.ts Normal file
View File

@@ -0,0 +1,194 @@
import { execSync } from "child_process";
import { readFileSync, writeFileSync, mkdirSync } from "fs";
import { dirname } from "path";
export interface ToolDefinition {
name: string;
description: string;
input_schema: Record<string, unknown>;
}
export const toolDefs: ToolDefinition[] = [
{
name: "bash",
description:
"Execute a bash command. Returns stdout and stderr. Use for running commands, installing packages, git, etc. Timeout: 120s.",
input_schema: {
type: "object",
properties: {
command: {
type: "string",
description: "The bash command to execute",
},
},
required: ["command"],
},
},
{
name: "read_file",
description:
"Read the contents of a file. Returns the text content. Use for examining code, configs, etc.",
input_schema: {
type: "object",
properties: {
path: { type: "string", description: "Path to the file" },
limit: {
type: "number",
description: "Max lines to read (default: all)",
},
offset: {
type: "number",
description: "Line to start from, 1-indexed (default: 1)",
},
},
required: ["path"],
},
},
{
name: "write_file",
description:
"Write content to a file. Creates parent directories if needed. Overwrites existing files.",
input_schema: {
type: "object",
properties: {
path: { type: "string", description: "Path to the file" },
content: {
type: "string",
description: "Content to write",
},
},
required: ["path", "content"],
},
},
{
name: "edit_file",
description:
"Edit a file by replacing exact text. oldText must match exactly including whitespace.",
input_schema: {
type: "object",
properties: {
path: { type: "string", description: "Path to the file" },
old_text: {
type: "string",
description: "Exact text to find",
},
new_text: {
type: "string",
description: "Replacement text",
},
},
required: ["path", "old_text", "new_text"],
},
},
{
name: "done",
description:
"Call this when the task is fully complete. Provide a short summary of what was accomplished.",
input_schema: {
type: "object",
properties: {
summary: {
type: "string",
description: "Short summary of what was done",
},
},
required: ["summary"],
},
},
{
name: "ask_user",
description:
"Ask the user a question when you need clarification or a decision. The user will respond via Telegram.",
input_schema: {
type: "object",
properties: {
question: {
type: "string",
description: "The question to ask",
},
},
required: ["question"],
},
},
];
export function executeTool(
name: string,
input: Record<string, unknown>
): { result: string; isDone?: boolean; isQuestion?: boolean } {
try {
switch (name) {
case "bash": {
const cmd = input.command as string;
try {
const output = execSync(cmd, {
encoding: "utf-8",
timeout: 120_000,
maxBuffer: 1024 * 1024,
cwd: process.cwd(),
});
const trimmed = output.length > 10000
? output.slice(0, 10000) + "\n...(truncated)"
: output;
return { result: trimmed || "(no output)" };
} catch (e: any) {
const stderr = e.stderr || "";
const stdout = e.stdout || "";
return {
result: `Exit code: ${e.status}\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`.slice(
0,
5000
),
};
}
}
case "read_file": {
const path = input.path as string;
const content = readFileSync(path, "utf-8");
const lines = content.split("\n");
const offset = ((input.offset as number) || 1) - 1;
const limit = (input.limit as number) || lines.length;
const slice = lines.slice(offset, offset + limit).join("\n");
return {
result:
slice.length > 10000
? slice.slice(0, 10000) + "\n...(truncated)"
: slice,
};
}
case "write_file": {
const path = input.path as string;
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, input.content as string, "utf-8");
return { result: `Written ${(input.content as string).length} bytes to ${path}` };
}
case "edit_file": {
const path = input.path as string;
const content = readFileSync(path, "utf-8");
const oldText = input.old_text as string;
const newText = input.new_text as string;
if (!content.includes(oldText)) {
return { result: `ERROR: old_text not found in ${path}` };
}
writeFileSync(path, content.replace(oldText, newText), "utf-8");
return { result: `Edited ${path}` };
}
case "done": {
return { result: input.summary as string, isDone: true };
}
case "ask_user": {
return { result: input.question as string, isQuestion: true };
}
default:
return { result: `Unknown tool: ${name}` };
}
} catch (e: any) {
return { result: `Tool error: ${e.message}` };
}
}