Files

574 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
readFileSync,
existsSync,
mkdirSync,
statSync,
unlinkSync,
} from "fs";
import { Bot } from "grammy";
import { join } from "path";
// ─── Config ───
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN!;
const ALLOWED_USERS = (process.env.TELEGRAM_ALLOWED_USERS || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const SSH_USER = process.env.SSH_USER || "root";
const SSH_HOST = process.env.SSH_HOST || "159.195.60.33";
const SSH_PORT = process.env.SSH_PORT || "22";
const PI_MODEL = process.env.PI_MODEL || "claude-sonnet-4-6";
const PI_TIMEOUT_MS = 10 * 60 * 1000; // 10 min
const SESSION_DIR = join(import.meta.dir, "sessions");
const SESSION_WARN_KB = 80_000;
const SESSION_MAX_KB = 100_000;
const MAX_RETRY = 1;
const RATE_LIMIT_WINDOW = 60_000;
const RATE_LIMIT_MAX = 15;
// ─── State ───
const activeProcesses = new Map<number, AbortController>();
const rateLimits = new Map<number, number[]>();
// Ensure session dir
if (!existsSync(SESSION_DIR)) mkdirSync(SESSION_DIR, { recursive: true });
// ─── Infrastructure context ───
function loadInfraContext(): string {
try {
const infraPath = join(import.meta.dir, "../.pi/infra.md");
if (existsSync(infraPath)) return readFileSync(infraPath, "utf-8");
} catch {}
return "";
}
const INFRA_CONTEXT = loadInfraContext();
// ─── System Prompt ───
const SYSTEM_PROMPT = `You are a DevOps management bot for CharityRight, operated via Telegram.
INFRASTRUCTURE:
- Primary server: ${SSH_USER}@${SSH_HOST}:${SSH_PORT}
- Containers: cr-server-new (CharityRight), qc-server-new (QuikCue) via Incus
- Services: Docker, Dokploy, Postgres, Redis, sync services
${INFRA_CONTEXT ? `\nDETAILED INFRA:\n${INFRA_CONTEXT}` : ""}
RULES:
1. ALL server commands MUST use: ssh ${SSH_USER}@${SSH_HOST} -p ${SSH_PORT} "command"
2. For containers: ssh ${SSH_USER}@${SSH_HOST} "incus exec cr-server-new -- command"
3. NEVER run commands directly on the local machine
4. NEVER read local files, .env files, or secrets
5. NEVER run destructive commands: rm -rf /, mkfs, dd, DROP DATABASE
6. Format responses clearly with status indicators
7. Be concise — this is a mobile chat interface`;
// ─── Dangerous command patterns ───
const DANGEROUS_PATTERNS = [
/rm\s+-rf\s+\/(?!\w)/,
/mkfs\./,
/dd\s+if=/,
/:(){ :\|:& };:/,
/shutdown/,
/reboot/,
/init\s+0/,
/DROP\s+(DATABASE|TABLE)/i,
/TRUNCATE\s+TABLE/i,
/DELETE\s+FROM\s+\w+\s*;/i,
];
// ─── Bot ───
const bot = new Bot(BOT_TOKEN);
function isAllowed(userId: number): boolean {
if (ALLOWED_USERS.length === 0) return false;
return ALLOWED_USERS.includes(String(userId));
}
function checkRateLimit(userId: number): boolean {
const now = Date.now();
const timestamps = rateLimits.get(userId) || [];
const recent = timestamps.filter((t) => now - t < RATE_LIMIT_WINDOW);
recent.push(now);
rateLimits.set(userId, recent);
return recent.length <= RATE_LIMIT_MAX;
}
function getSessionPath(userId: number): string {
return join(SESSION_DIR, `user-${userId}.jsonl`);
}
function checkSession(userId: number): { exists: boolean; sizeKB: number } {
const path = getSessionPath(userId);
if (!existsSync(path)) return { exists: false, sizeKB: 0 };
const stat = statSync(path);
return { exists: true, sizeKB: Math.round(stat.size / 1024) };
}
// ─── SSH helper ───
async function sshExec(
command: string,
timeoutMs = 30_000
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(
[
"ssh",
"-o", "StrictHostKeyChecking=no",
"-o", "ConnectTimeout=10",
"-p", SSH_PORT,
`${SSH_USER}@${SSH_HOST}`,
command,
],
{ stdout: "pipe", stderr: "pipe" }
);
const timer = setTimeout(() => proc.kill(), timeoutMs);
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
clearTimeout(timer);
const exitCode = await proc.exited;
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
}
// ─── Pi Streaming ───
async function callPiStreaming(
userId: number,
message: string,
signal: AbortSignal,
ctx: any,
retries = MAX_RETRY
): Promise<string> {
const session = getSessionPath(userId);
const hasSession = existsSync(session);
const args = [
"pi",
"--mode", "json",
"-p",
"--no-extensions",
"--model", PI_MODEL,
"--tools", "bash",
"--thinking", "off",
"--append-system-prompt", SYSTEM_PROMPT,
];
if (hasSession) {
args.push("--session", session, "--continue");
} else {
args.push("--session", session);
}
args.push(message);
// Strip secrets from child env
const { ANTHROPIC_API_KEY: _removed, ...cleanEnv } = process.env;
const proc = Bun.spawn({
cmd: args,
cwd: "/tmp",
stdout: "pipe",
stderr: "pipe",
env: cleanEnv,
});
const timeout = setTimeout(() => {
console.log(`⏰ [${userId}] Pi timed out`);
proc.kill();
}, PI_TIMEOUT_MS);
const abortHandler = () => proc.kill();
signal.addEventListener("abort", abortHandler, { once: true });
let finalText = "";
let toolCalls = 0;
let progressMsgId: number | null = null;
let lastProgressUpdate = 0;
try {
const reader = proc.stdout.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
// Tool execution — update single progress message
if (event.type === "tool_execution_start") {
toolCalls++;
const input = event.tool_input;
let preview = "";
if (input?.command) {
preview = input.command.substring(0, 100);
} else if (input?.path) {
preview = `📄 ${input.path}`;
} else {
preview = event.tool_name || "working";
}
const progressText = `⚙️ [${toolCalls}] ${preview}...`;
const now = Date.now();
// Throttle to every 2s
if (now - lastProgressUpdate > 2000) {
try {
if (progressMsgId) {
await ctx.api.editMessageText(ctx.chat.id, progressMsgId, progressText);
} else {
const sent = await ctx.reply(progressText);
progressMsgId = sent.message_id;
}
lastProgressUpdate = now;
} catch {}
}
}
// Agent end — extract final assistant response
if (event.type === "agent_end" && event.messages) {
const allText: string[] = [];
for (const msg of event.messages) {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "text" && block.text) allText.push(block.text);
}
}
}
if (allText.length > 0) {
finalText = allText[allText.length - 1];
}
}
} catch {}
}
}
// Drain stderr
const stderrText = await new Response(proc.stderr).text();
clearTimeout(timeout);
signal.removeEventListener("abort", abortHandler);
const exitCode = await proc.exited;
// Delete progress message
if (progressMsgId) {
try {
await ctx.api.deleteMessage(ctx.chat.id, progressMsgId);
} catch {}
}
// Handle corrupted session — retry with counter
if (
exitCode !== 0 &&
hasSession &&
retries > 0 &&
(stderrText.includes("session") || stderrText.includes("corrupt") || stderrText.includes("parse"))
) {
console.log(`🔄 [${userId}] Corrupted session — clearing and retrying (${retries} left)...`);
try {
unlinkSync(session);
} catch {}
return callPiStreaming(userId, message, signal, ctx, retries - 1);
}
if (!finalText.trim()) {
if (stderrText) return `⚠️ Pi error: ${stderrText.substring(0, 500)}`;
return "⚠️ No response from Pi agent.";
}
return finalText;
} catch (error: any) {
clearTimeout(timeout);
signal.removeEventListener("abort", abortHandler);
if (progressMsgId) {
try {
await ctx.api.deleteMessage(ctx.chat.id, progressMsgId);
} catch {}
}
if (signal.aborted) return "🛑 Task cancelled.";
return `❌ Error: ${error.message}`;
}
}
// ─── Command Handlers ───
bot.command("start", async (ctx) => {
if (!isAllowed(ctx.from?.id || 0)) return;
await ctx.reply(
`🤖 <b>CharityRight DevOps Bot</b>\n\n` +
`<b>Commands:</b>\n` +
`/status — Server health check\n` +
`/ssh &lt;cmd&gt; — Run SSH command\n` +
`/health — Pi Worker status\n` +
`/asana — List Asana tasks\n` +
`/clear — Reset conversation\n` +
`/stop — Cancel running task\n\n` +
`Or just type naturally — I'll figure it out.`,
{ parse_mode: "HTML" }
);
});
bot.command("status", async (ctx) => {
if (!isAllowed(ctx.from?.id || 0)) return;
const msg = await ctx.reply("🔍 Checking server status...");
try {
const { stdout, exitCode } = await sshExec(
"uptime && echo '---' && df -h / | tail -1 && echo '---' && free -h | grep Mem && echo '---' && incus list -f compact 2>/dev/null || true"
);
const status = exitCode === 0 ? "✅" : "⚠️";
await ctx.api.editMessageText(
ctx.chat.id,
msg.message_id,
`${status} <b>Server Status</b>\n<pre>${stdout.substring(0, 3500)}</pre>`,
{ parse_mode: "HTML" }
);
} catch (error: any) {
await ctx.api.editMessageText(ctx.chat.id, msg.message_id, `❌ SSH failed: ${error.message}`);
}
});
bot.command("ssh", async (ctx) => {
if (!isAllowed(ctx.from?.id || 0)) return;
const cmd = ctx.match;
if (!cmd) {
await ctx.reply("Usage: /ssh <command>");
return;
}
if (DANGEROUS_PATTERNS.some((p) => p.test(cmd))) {
await ctx.reply("🚫 Blocked — dangerous command detected.");
return;
}
const msg = await ctx.reply(`⚙️ Running: <code>${cmd.substring(0, 200)}</code>`, { parse_mode: "HTML" });
try {
const { stdout, stderr, exitCode } = await sshExec(cmd);
const output = stdout || stderr || "(no output)";
const status = exitCode === 0 ? "✅" : `⚠️ Exit ${exitCode}`;
await ctx.api.editMessageText(
ctx.chat.id,
msg.message_id,
`${status}\n<pre>${output.substring(0, 3500)}</pre>`,
{ parse_mode: "HTML" }
);
} catch (error: any) {
await ctx.api.editMessageText(ctx.chat.id, msg.message_id, `${error.message}`);
}
});
bot.command("health", async (ctx) => {
if (!isAllowed(ctx.from?.id || 0)) return;
const msg = await ctx.reply("🔍 Checking Pi Worker...");
try {
const { stdout } = await sshExec("incus exec cr-server-new -- curl -s http://localhost:8787/health");
const health = JSON.parse(stdout);
const statusEmoji = health.status === "ok" ? "✅" : "⚠️";
await ctx.api.editMessageText(
ctx.chat.id,
msg.message_id,
`${statusEmoji} <b>Pi Worker</b>\n` +
`Status: ${health.status}\n` +
`Uptime: ${Math.floor(health.uptime / 60)}m\n` +
`Task Loop: ${health.taskLoop ? "✅" : "❌"}\n` +
`Improvement Loop: ${health.improvementLoop ? "✅" : "❌"}`,
{ parse_mode: "HTML" }
);
} catch (error: any) {
await ctx.api.editMessageText(ctx.chat.id, msg.message_id, `❌ Pi Worker unreachable: ${error.message}`);
}
});
bot.command("asana", async (ctx) => {
if (!isAllowed(ctx.from?.id || 0)) return;
const msg = await ctx.reply("🔍 Fetching Asana tasks...");
try {
const token = process.env.ASANA_ACCESS_TOKEN;
if (!token) {
await ctx.api.editMessageText(ctx.chat.id, msg.message_id, "❌ No ASANA_ACCESS_TOKEN");
return;
}
const res = await fetch(
"https://app.asana.com/api/1.0/user_task_lists/1212299150915258/tasks?opt_fields=name,completed,assignee_status&limit=20",
{ headers: { Authorization: `Bearer ${token}` } }
);
const data = (await res.json()) as any;
const tasks = data.data || [];
const open = tasks.filter((t: any) => !t.completed);
let text = `📋 <b>My Tasks</b> (${open.length} open)\n\n`;
for (const t of open.slice(0, 15)) {
text += `${t.name.substring(0, 60)}\n`;
}
if (open.length > 15) text += `\n... and ${open.length - 15} more`;
await ctx.api.editMessageText(ctx.chat.id, msg.message_id, text, { parse_mode: "HTML" });
} catch (error: any) {
await ctx.api.editMessageText(ctx.chat.id, msg.message_id, `❌ Asana error: ${error.message}`);
}
});
bot.command("clear", async (ctx) => {
if (!isAllowed(ctx.from?.id || 0)) return;
const userId = ctx.from!.id;
const session = getSessionPath(userId);
try {
unlinkSync(session);
} catch {}
await ctx.reply("🧹 Session cleared.");
});
bot.command("stop", async (ctx) => {
const userId = ctx.from?.id || 0;
const controller = activeProcesses.get(userId);
if (controller) {
controller.abort();
activeProcesses.delete(userId);
await ctx.reply("🛑 Stopped.");
} else {
await ctx.reply("️ Nothing running.");
}
});
bot.command("cancel", async (ctx) => {
const userId = ctx.from?.id || 0;
const controller = activeProcesses.get(userId);
if (controller) {
controller.abort();
activeProcesses.delete(userId);
await ctx.reply("🛑 Cancelled.");
} else {
await ctx.reply("️ Nothing running.");
}
});
// ─── Natural language messages ───
bot.on("message:text", async (ctx) => {
const userId = ctx.from?.id || 0;
if (!isAllowed(userId)) return;
if (!checkRateLimit(userId)) {
await ctx.reply("⏳ Slow down — rate limit reached.");
return;
}
const msg = ctx.message.text;
// Block concurrent tasks per user
if (activeProcesses.has(userId)) {
await ctx.reply("⏳ Already processing a task. Use /stop to cancel it first.");
return;
}
// Session size management
const sessionInfo = checkSession(userId);
if (sessionInfo.sizeKB > SESSION_WARN_KB) {
if (sessionInfo.sizeKB > SESSION_MAX_KB) {
try {
unlinkSync(getSessionPath(userId));
} catch {}
await ctx.reply(
`🧹 Session auto-cleared (${sessionInfo.sizeKB}KB exceeded ${SESSION_MAX_KB}KB limit). Starting fresh.`
);
} else {
await ctx.reply(`⚠️ Session is ${sessionInfo.sizeKB}KB/${SESSION_MAX_KB}KB. Consider /clear soon.`);
}
}
const controller = new AbortController();
activeProcesses.set(userId, controller);
try {
const response = await callPiStreaming(userId, msg, controller.signal, ctx);
// Send response in chunks (Telegram 4096 char limit)
const maxLen = 4000;
for (let i = 0; i < response.length; i += maxLen) {
const chunk = response.substring(i, i + maxLen);
await ctx.reply(chunk);
}
} catch (error: any) {
if (!controller.signal.aborted) {
await ctx.reply(`❌ Error: ${error.message}`);
}
} finally {
activeProcesses.delete(userId);
}
});
// ─── Error handler ───
bot.catch((err) => {
const msg = err.message || String(err);
// 409 is expected during restart — ignore it
if (msg.includes("409") || msg.includes("Conflict")) {
console.log("⚠️ Telegram 409 conflict (normal during restart, retrying...)");
return;
}
console.error("Bot error:", msg);
});
// ─── Startup ───
console.log("🤖 CharityRight DevOps Bot starting...");
const BOT_API = `https://api.telegram.org/bot${BOT_TOKEN}`;
let pollOffset = 0;
let running = true;
async function pollLoop() {
// Grammy requires bot.init() before handleUpdate() can work
await bot.init();
console.log("✅ Bot online — polling for messages");
while (running) {
try {
const res = await fetch(`${BOT_API}/getUpdates?offset=${pollOffset}&limit=100&timeout=30`);
const data = (await res.json()) as any;
if (!data.ok) {
if (data.error_code === 409) {
console.log("⚠️ 409 conflict — waiting 10s...");
await new Promise((r) => setTimeout(r, 10_000));
continue;
}
console.error("Poll error:", data.description);
await new Promise((r) => setTimeout(r, 3000));
continue;
}
for (const update of data.result || []) {
pollOffset = update.update_id + 1;
try {
await bot.handleUpdate(update);
} catch (e: any) {
console.error("Handler error:", e.message?.substring(0, 200));
}
}
} catch (e: any) {
console.error("Network error:", e.message?.substring(0, 100));
await new Promise((r) => setTimeout(r, 3000));
}
}
}
await pollLoop();
const cleanup = () => {
console.log("🛑 Shutting down...");
running = false;
for (const [, controller] of activeProcesses) controller.abort();
process.exit(0);
};
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
process.on("unhandledRejection", (err: any) => {
console.error("Unhandled:", err?.message?.substring(0, 200) || err);
});