/** * Admin command handlers — /admin subcommands for bot operators. */ import type { Bot, Context } from "grammy"; import { readFileSync } from "node:fs"; import type { TalonConfig } from "../../util/config.js"; import { files, dirs } from "../../util/paths.js"; import { tailFile } from "../../util/tail-file.js"; import { escapeHtml } from "./formatting.js"; import { resetSession, getAllSessions } from "../../storage/sessions.js"; import { clearHistory } from "../../storage/history.js"; import { todayLogDate } from "../../storage/daily-log.js"; import { getChatSettings } from "../../storage/chat-settings.js"; import { getAllCronJobs, describeSchedule, nextRunAt, } from "../../storage/cron-store.js"; import { getActiveCount } from "../../core/engine/dispatcher.js"; import { getPulseStatus } from "../../core/background/pulse.js"; import { getHealthStatus, getRecentErrors } from "../../util/watchdog.js"; import { formatDuration, formatModelLabel } from "./helpers/index.js"; export async function handleAdminCommand( ctx: Context, bot: Bot, config: TalonConfig, ): Promise { const args = ((ctx.match as string) ?? "").trim(); const [subcommand, ...rest] = args.split(/\s+/); switch (subcommand) { case "chats": { const sessions = getAllSessions(); if (sessions.length === 0) { await ctx.reply("No active sessions."); return; } sessions.sort( (a, b) => (b.info.lastActive || 0) - (a.info.lastActive || 0), ); const titles = new Map(); await Promise.all( sessions.map(async (s) => { try { const id = parseInt(s.chatId, 10); if (isNaN(id)) return; const chat = await bot.api.getChat(id); titles.set( s.chatId, "title" in chat ? (chat.title ?? "DM") : "first_name" in chat ? (chat.first_name ?? "DM") : "DM", ); } catch { /* inaccessible */ } }), ); const lines = sessions.map((s) => { const age = s.info.lastActive ? `${Math.round((Date.now() - s.info.lastActive) / 60000)}m ago` : "?"; const title = titles.get(s.chatId) ?? s.chatId; const model = formatModelLabel( getChatSettings(s.chatId).model ?? config.model, ); // `model` is a catalog id (OpenRouter/Kilo ids are free-form), so // it gets the same escaping the title already had. return `${escapeHtml(title)} ${s.chatId}\n ${s.info.turns} turns | ${age} | ${escapeHtml(model)}`; }); await ctx.reply( `Active chats (${sessions.length})\n\n` + lines.join("\n\n"), { parse_mode: "HTML" }, ); return; } case "broadcast": { const text = rest.join(" "); if (!text) { await ctx.reply("Usage: /admin broadcast "); return; } const sessions = getAllSessions(); let sent = 0, failed = 0; for (const s of sessions) { const id = parseInt(s.chatId, 10); if (isNaN(id)) continue; try { await bot.api.sendMessage(id, text); sent++; await new Promise((r) => setTimeout(r, 40)); } catch { failed++; } } await ctx.reply( `Broadcast: ${sent} sent, ${failed} failed (${sessions.length} total).`, ); return; } case "kill": { const target = rest[0]; if (!target) { await ctx.reply("Usage: /admin kill "); return; } resetSession(target); clearHistory(target); await ctx.reply(`Session ${target} reset.`); return; } case "logs": { const logPath = files.log; try { const lines = tailFile(logPath); await ctx.reply(`
${escapeHtml(lines.slice(0, 3800))}
`, { parse_mode: "HTML", }); } catch { await ctx.reply(`Could not read ${logPath}`); } return; } case "stats": { const h = getHealthStatus(); const sessions = getAllSessions(); const turns = sessions.reduce((s, x) => s + x.info.turns, 0); const mem = process.memoryUsage(); await ctx.reply( [ `\uD83E\uDD85 Talon Stats`, "", `Uptime: ${formatDuration(h.uptimeMs)}`, `Messages: ${h.totalMessagesProcessed}`, `Sessions: ${sessions.length}`, `Turns: ${turns}`, `Last active: ${h.msSinceLastMessage < 60000 ? "now" : formatDuration(h.msSinceLastMessage) + " ago"}`, "", `Memory: ${(mem.heapUsed / 1024 / 1024).toFixed(1)}MB heap / ${(mem.rss / 1024 / 1024).toFixed(1)}MB rss`, `Queue: ${getActiveCount()}`, `Errors: ${h.recentErrorCount}`, ].join("\n"), { parse_mode: "HTML" }, ); return; } case "errors": { const errors = getRecentErrors(5); if (errors.length === 0) { await ctx.reply("No recent errors."); return; } const lines = errors.map( (e) => `[${new Date(e.timestamp).toISOString().slice(11, 19)}] ${escapeHtml(e.message.slice(0, 200))}`, ); await ctx.reply( `Recent Errors (${errors.length})\n\n` + lines.join("\n\n"), { parse_mode: "HTML" }, ); return; } case "cron": { const jobs = getAllCronJobs(); if (jobs.length === 0) { await ctx.reply("No cron jobs."); return; } const lines = jobs.map((j) => { const nextMs = nextRunAt(j); const last = j.lastRunAt ? new Date(j.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never"; const next = nextMs ? new Date(nextMs).toISOString().slice(0, 16).replace("T", " ") : "?"; return `${j.enabled ? "\u2713" : "\u2717"} ${escapeHtml(j.name)}\n ${escapeHtml(describeSchedule(j))} | ${j.type} | runs: ${j.runCount} | last: ${last} | next: ${next}`; }); await ctx.reply( `Cron Jobs (${jobs.length})\n\n` + lines.join("\n\n"), { parse_mode: "HTML" }, ); return; } case "pulse": { const chats = getPulseStatus(); if (chats.length === 0) { await ctx.reply("No pulse chats."); return; } const lines = await Promise.all( chats.map(async (p) => { let title = p.chatId; try { const id = parseInt(p.chatId, 10); if (!isNaN(id)) { const chat = await bot.api.getChat(id); title = "title" in chat ? (chat.title ?? p.chatId) : p.chatId; } } catch { /* skip */ } return `${p.enabled ? "\u2713" : "\u2717"} ${escapeHtml(title)}`; }), ); await ctx.reply(`Pulse (${chats.length})\n\n` + lines.join("\n"), { parse_mode: "HTML", }); return; } case "daily": { const today = todayLogDate(); const logPath = `${dirs.logs}/${today}.md`; try { const content = readFileSync(logPath, "utf-8"); const lines = content.trim().split("\n").slice(-30).join("\n"); await ctx.reply( `Daily log (${today})\n\n
${escapeHtml(lines.slice(0, 3800))}
`, { parse_mode: "HTML" }, ); } catch { await ctx.reply(`No daily log for ${today}.`); } return; } default: await ctx.reply( [ "/admin commands", "", " stats uptime, messages, memory", " errors last 5 errors", " chats list all active chats", " daily today's interaction log", " pulse pulse status per chat", " cron list all cron jobs", " broadcast <text> send to all chats", " kill <chatId> reset a chat session", " logs last 20 lines of log", ].join("\n"), { parse_mode: "HTML" }, ); } }