update extensions, telegram bot, and add giveaway module
This commit is contained in:
@@ -0,0 +1,641 @@
|
||||
/**
|
||||
* EasyMode — All-in-one beginner-friendly Pi extension
|
||||
*
|
||||
* Built-in features:
|
||||
* 🎯 Goal Keeper — set a focus so the AI stays on track
|
||||
* 🛡️ Safety Guard — catches dangerous commands before they run
|
||||
* 📊 Smart Footer — model, context bar, cost, git branch, tool tally
|
||||
* 🤖 Agent Presets — switch AI personality with /agent
|
||||
* 🟢 /menu — visual command picker (no memorization needed)
|
||||
* 🛑 /stop — abort the current AI action
|
||||
* 💡 /explain — re-explain the last action in plain English
|
||||
* 🔖 /bookmark — save + list important moments
|
||||
* ↩️ /undo — how to revert the last file change
|
||||
* 📋 /status — full session dashboard
|
||||
* 📖 /help — quick reference
|
||||
* 🔄 /quickstart — re-run the welcome wizard
|
||||
*
|
||||
* Usage: pi -e extensions/easymode.ts
|
||||
*
|
||||
* Or with theme cycling:
|
||||
* pi -e extensions/easymode.ts -e extensions/theme-cycler.ts
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
|
||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||||
import { basename } from "node:path";
|
||||
import { applyExtensionDefaults } from "./themeMap.ts";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Agent Presets
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
interface AgentPreset {
|
||||
name: string;
|
||||
emoji: string;
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
}
|
||||
|
||||
const AGENT_PRESETS: AgentPreset[] = [
|
||||
{
|
||||
name: "Default",
|
||||
emoji: "🤖",
|
||||
description: "Standard coding assistant — no restrictions",
|
||||
systemPrompt: "",
|
||||
},
|
||||
{
|
||||
name: "Explain Like I'm 5",
|
||||
emoji: "👶",
|
||||
description: "Explains everything in the simplest terms possible",
|
||||
systemPrompt: `You are a patient teacher. The user is a beginner.
|
||||
- Explain EVERY concept in simple terms before using it
|
||||
- Use analogies and real-world comparisons
|
||||
- Show the "before" and "after" of every change
|
||||
- Never assume the user knows technical jargon — define it first
|
||||
- After each code change, add a brief "What just happened?" summary
|
||||
- If a command could break something, warn the user FIRST`,
|
||||
},
|
||||
{
|
||||
name: "Guided Builder",
|
||||
emoji: "🏗️",
|
||||
description: "Step-by-step building with confirmations at each stage",
|
||||
systemPrompt: `You are a guided builder. Help the user build things step-by-step.
|
||||
- Break every task into numbered steps and show the plan first
|
||||
- Ask for confirmation before each major step
|
||||
- After each change, briefly explain what you did and why
|
||||
- If there are multiple approaches, list 2-3 options with pros/cons
|
||||
- Always suggest the safest option as the default
|
||||
- Show file paths and explain the project structure as you go`,
|
||||
},
|
||||
{
|
||||
name: "Code Reviewer",
|
||||
emoji: "🔍",
|
||||
description: "Reviews code and suggests improvements without changing anything",
|
||||
systemPrompt: `You are a code reviewer. Review, don't modify.
|
||||
- DO NOT write or edit any files unless explicitly asked
|
||||
- Point out bugs, security issues, and improvement opportunities
|
||||
- Rate code quality on a 1-5 scale with specific reasons
|
||||
- Suggest improvements as clear before/after examples
|
||||
- Focus on: readability, security, performance, maintainability
|
||||
- Be encouraging — highlight what's done well too`,
|
||||
},
|
||||
{
|
||||
name: "Debug Detective",
|
||||
emoji: "🕵️",
|
||||
description: "Focused on finding and fixing bugs",
|
||||
systemPrompt: `You are a debug detective. Help find and fix bugs systematically.
|
||||
- Always ask to see error messages and logs first
|
||||
- Explain your debugging thought process step by step
|
||||
- List possible causes ranked by likelihood
|
||||
- Test hypotheses one at a time
|
||||
- After fixing, explain what caused the bug and how to prevent it
|
||||
- Suggest adding error handling or tests to prevent recurrence`,
|
||||
},
|
||||
{
|
||||
name: "Safe Mode",
|
||||
emoji: "🔒",
|
||||
description: "Extra cautious — confirms every file modification",
|
||||
systemPrompt: `You are in Safe Mode. Maximum caution.
|
||||
- ALWAYS show the exact changes you plan to make BEFORE making them
|
||||
- Ask for explicit "yes" confirmation before ANY file write, edit, or bash command
|
||||
- Never run destructive commands (rm, drop, truncate, etc.) without double-confirming
|
||||
- Create backups before modifying existing files when possible
|
||||
- After each change, verify the change was correct by reading the file back
|
||||
- If something goes wrong, immediately suggest how to undo it`,
|
||||
},
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Dangerous Command Patterns
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const DANGEROUS_PATTERNS: { pattern: RegExp; reason: string }[] = [
|
||||
{ pattern: /rm\s+(-[rRf]+\s+|.*\s+-[rRf]+)/, reason: "Recursive/forced delete detected" },
|
||||
{ pattern: /rm\s+-rf\s+[\/~]/, reason: "Deleting from root or home directory!" },
|
||||
{ pattern: /DROP\s+(TABLE|DATABASE|SCHEMA)/i, reason: "SQL DROP command detected" },
|
||||
{ pattern: /TRUNCATE\s+TABLE/i, reason: "SQL TRUNCATE command detected" },
|
||||
{ pattern: /DELETE\s+FROM\s+\w+\s*(;|$)/i, reason: "DELETE without WHERE clause" },
|
||||
{ pattern: />\s*\/dev\/sd[a-z]/, reason: "Writing directly to disk device" },
|
||||
{ pattern: /mkfs\./, reason: "Filesystem format command detected" },
|
||||
{ pattern: /dd\s+if=/, reason: "dd command — raw disk copy" },
|
||||
{ pattern: /chmod\s+-R\s+777/, reason: "Recursive world-writable permissions" },
|
||||
{ pattern: /:(){ :\|:& };:/, reason: "Fork bomb detected!" },
|
||||
{ pattern: /curl.*\|\s*(ba)?sh/, reason: "Piping remote script to shell" },
|
||||
{ pattern: /wget.*\|\s*(ba)?sh/, reason: "Piping remote script to shell" },
|
||||
{ pattern: /git\s+push\s+.*--force/, reason: "Force push — may overwrite remote history" },
|
||||
{ pattern: /git\s+reset\s+--hard/, reason: "Hard reset — may lose uncommitted changes" },
|
||||
{ pattern: /npm\s+publish/, reason: "Publishing to npm registry" },
|
||||
{ pattern: /docker\s+system\s+prune/, reason: "Docker system prune — removes all unused data" },
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Extension
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let purpose: string | undefined;
|
||||
let activePreset: AgentPreset = AGENT_PRESETS[1]; // Default to "Explain Like I'm 5" for beginners
|
||||
let activeCtx: ExtensionContext | undefined;
|
||||
const toolCounts: Record<string, number> = {};
|
||||
const bookmarks: { timestamp: string; note: string }[] = [];
|
||||
let lastFileChange: { tool: string; path: string } | null = null;
|
||||
let toolRunsSinceLastTip = 0;
|
||||
|
||||
// ── Tool tracking + contextual tips ───────────────────────────────
|
||||
pi.on("tool_execution_end", async (event) => {
|
||||
toolCounts[event.toolName] = (toolCounts[event.toolName] || 0) + 1;
|
||||
|
||||
// Show a helpful hint every 5 tool runs
|
||||
toolRunsSinceLastTip++;
|
||||
if (activeCtx && toolRunsSinceLastTip >= 5) {
|
||||
toolRunsSinceLastTip = 0;
|
||||
const tips = [
|
||||
"💡 Confused? Type /explain and the AI will break down what just happened.",
|
||||
"💡 Want to save your progress? Type /bookmark to mark this moment.",
|
||||
"💡 Type /menu to see everything you can do.",
|
||||
"💡 Things going wrong? Type /stop to pause the AI immediately.",
|
||||
"💡 Type /undo to see how to reverse the last file change.",
|
||||
"💡 Type /status to see a summary of your whole session.",
|
||||
];
|
||||
const tip = tips[Math.floor(Math.random() * tips.length)];
|
||||
activeCtx.ui.notify(tip, "info");
|
||||
}
|
||||
});
|
||||
|
||||
// ── Track file changes for /undo ──────────────────────────────────
|
||||
pi.on("tool_call", async (event, _ctx) => {
|
||||
if (isToolCallEventType("write", event) || isToolCallEventType("edit", event)) {
|
||||
lastFileChange = { tool: event.toolName, path: event.input.path };
|
||||
}
|
||||
return { block: false };
|
||||
});
|
||||
|
||||
// ── Welcome Wizard ────────────────────────────────────────────────
|
||||
async function runWelcomeWizard(ctx: ExtensionContext) {
|
||||
// Step 1: Welcome
|
||||
ctx.ui.notify(
|
||||
"👋 Welcome to EasyMode!\n\n" +
|
||||
"This is your AI coding assistant. You type what you want\n" +
|
||||
"in plain English, and it writes the code for you.\n\n" +
|
||||
"Let's get you set up in 30 seconds...",
|
||||
"info"
|
||||
);
|
||||
|
||||
// Step 2: Pick a personality
|
||||
const personalityOptions = AGENT_PRESETS.map((p) => `${p.emoji} ${p.name} — ${p.description}`);
|
||||
const personalityChoice = await ctx.ui.select(
|
||||
"Step 1 of 2: How should the AI talk to you?",
|
||||
personalityOptions
|
||||
);
|
||||
|
||||
if (personalityChoice !== undefined) {
|
||||
const idx = personalityOptions.indexOf(personalityChoice);
|
||||
activePreset = AGENT_PRESETS[idx];
|
||||
ctx.ui.setStatus("easymode", `${activePreset.emoji} ${activePreset.name}`);
|
||||
ctx.ui.notify(`Great choice! ${activePreset.emoji} ${activePreset.name} activated.`, "success");
|
||||
} else {
|
||||
ctx.ui.notify(`Using default: ${activePreset.emoji} ${activePreset.name}`, "info");
|
||||
}
|
||||
|
||||
// Step 3: Set a goal
|
||||
const answer = await ctx.ui.input(
|
||||
"Step 2 of 2: What do you want to do today?",
|
||||
"Examples: 'Make a website', 'Fix the bug on line 42', 'Explain this code to me'"
|
||||
);
|
||||
|
||||
if (answer && answer.trim()) {
|
||||
purpose = answer.trim();
|
||||
ctx.ui.notify(`🎯 Goal set: ${purpose}`, "success");
|
||||
setPurposeWidget(ctx);
|
||||
} else {
|
||||
ctx.ui.notify("No problem! You can set a goal anytime by typing /goal", "info");
|
||||
}
|
||||
|
||||
// Step 4: Quick orientation
|
||||
ctx.ui.notify(
|
||||
"✅ You're all set! Here's what you need to know:\n\n" +
|
||||
" 📝 Just type what you want in plain English\n" +
|
||||
" 🟢 /menu — see all available commands\n" +
|
||||
" 🛑 /stop — stop the AI if it's doing something wrong\n" +
|
||||
" ❓ /explain — ask the AI to explain what it just did\n\n" +
|
||||
"⚠️ The AI will ask for your OK before doing anything dangerous.\n\n" +
|
||||
"Go ahead — type your first message! 🚀",
|
||||
"success"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Session start ─────────────────────────────────────────────────
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
applyExtensionDefaults(import.meta.url, ctx);
|
||||
activeCtx = ctx;
|
||||
|
||||
// Fire-and-forget — don't block TUI startup
|
||||
void runWelcomeWizard(ctx);
|
||||
|
||||
// Footer
|
||||
ctx.ui.setFooter((tui, theme, footerData) => {
|
||||
const unsub = footerData.onBranchChange(() => tui.requestRender());
|
||||
|
||||
return {
|
||||
dispose: unsub,
|
||||
invalidate() {},
|
||||
render(width: number): string[] {
|
||||
// Accumulate tokens + cost
|
||||
let tokIn = 0, tokOut = 0, cost = 0;
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type === "message" && entry.message.role === "assistant") {
|
||||
const m = entry.message as AssistantMessage;
|
||||
tokIn += m.usage.input;
|
||||
tokOut += m.usage.output;
|
||||
cost += m.usage.cost.total;
|
||||
}
|
||||
}
|
||||
|
||||
const fmt = (n: number) => n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`;
|
||||
const model = ctx.model?.id || "no-model";
|
||||
const dir = basename(ctx.cwd);
|
||||
const branch = footerData.getGitBranch();
|
||||
|
||||
// Context bar
|
||||
const usage = ctx.getContextUsage();
|
||||
const pct = usage?.percent ?? 0;
|
||||
const filled = Math.round(pct / 10);
|
||||
const bar = "█".repeat(filled) + "░".repeat(10 - filled);
|
||||
|
||||
// Line 1: model + context + agent + cost
|
||||
const l1Left =
|
||||
theme.fg("dim", ` ${model} `) +
|
||||
(pct < 70 ? theme.fg("success", bar) : pct < 90 ? theme.fg("warning", bar) : theme.fg("error", bar)) +
|
||||
theme.fg("dim", ` ${Math.round(pct)}% `) +
|
||||
theme.fg("accent", `${activePreset.emoji} ${activePreset.name}`);
|
||||
|
||||
const l1Right =
|
||||
theme.fg("success", fmt(tokIn)) +
|
||||
theme.fg("dim", "↓ ") +
|
||||
theme.fg("accent", fmt(tokOut)) +
|
||||
theme.fg("dim", "↑ ") +
|
||||
theme.fg("warning", `$${cost.toFixed(4)}`) +
|
||||
theme.fg("dim", " ");
|
||||
|
||||
const pad1 = " ".repeat(Math.max(1, width - visibleWidth(l1Left) - visibleWidth(l1Right)));
|
||||
const line1 = truncateToWidth(l1Left + pad1 + l1Right, width, "");
|
||||
|
||||
// Line 2: cwd + branch + tools
|
||||
const l2Left =
|
||||
theme.fg("dim", ` ${dir}`) +
|
||||
(branch
|
||||
? theme.fg("dim", " ") + theme.fg("warning", "(") + theme.fg("success", branch) + theme.fg("warning", ")")
|
||||
: "");
|
||||
|
||||
const entries = Object.entries(toolCounts);
|
||||
const l2Right = entries.length === 0
|
||||
? theme.fg("dim", "ready ")
|
||||
: entries
|
||||
.map(([name, count]) => theme.fg("accent", name) + theme.fg("dim", ":") + theme.fg("success", `${count}`))
|
||||
.join(theme.fg("dim", " ")) + " ";
|
||||
|
||||
const pad2 = " ".repeat(Math.max(1, width - visibleWidth(l2Left) - visibleWidth(l2Right)));
|
||||
const line2 = truncateToWidth(l2Left + pad2 + l2Right, width, "");
|
||||
|
||||
return [line1, line2];
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
ctx.ui.setStatus("easymode", `${activePreset.emoji} ${activePreset.name}`);
|
||||
});
|
||||
|
||||
pi.on("session_switch", async (_event, ctx) => { activeCtx = ctx; });
|
||||
|
||||
// ── Safety guard ──────────────────────────────────────────────────
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
if (isToolCallEventType("bash", event)) {
|
||||
const cmd = event.input.command;
|
||||
for (const { pattern, reason } of DANGEROUS_PATTERNS) {
|
||||
if (pattern.test(cmd)) {
|
||||
const confirmed = await ctx.ui.confirm(
|
||||
"⚠️ Safety Warning",
|
||||
`${reason}\n\nCommand: ${cmd}\n\nAre you sure you want to run this?`,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
if (!confirmed) {
|
||||
ctx.ui.notify(`🛡️ Blocked: ${reason}`, "warning");
|
||||
ctx.abort();
|
||||
return {
|
||||
block: true,
|
||||
reason: `🛑 BLOCKED by Safety Guard: ${reason}\n\nThe user chose not to run this command. Ask them what they'd like to do instead.`,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { block: false };
|
||||
});
|
||||
|
||||
// ── System prompt injection ───────────────────────────────────────
|
||||
pi.on("before_agent_start", async (event) => {
|
||||
let extra = "";
|
||||
if (activePreset.systemPrompt) {
|
||||
extra += `\n\n<agent-preset name="${activePreset.name}">\n${activePreset.systemPrompt}\n</agent-preset>`;
|
||||
}
|
||||
if (purpose) {
|
||||
extra += `\n\n<user-goal>\nThe user's goal for this session: ${purpose}\nKeep this goal in mind. If the conversation drifts, gently guide back.\n</user-goal>`;
|
||||
}
|
||||
if (!extra) return;
|
||||
return { systemPrompt: event.systemPrompt + extra };
|
||||
});
|
||||
|
||||
// ── Helper ────────────────────────────────────────────────────────
|
||||
function setPurposeWidget(ctx: ExtensionContext) {
|
||||
ctx.ui.setWidget("purpose", () => ({
|
||||
render(width: number): string[] {
|
||||
const label = " ✅ Working on: ";
|
||||
const content = purpose!;
|
||||
const line = truncateToWidth(label + content, width - 2, "…");
|
||||
return [
|
||||
" ".repeat(width),
|
||||
line + " ".repeat(Math.max(0, width - visibleWidth(line))),
|
||||
" ".repeat(width),
|
||||
];
|
||||
},
|
||||
invalidate() {},
|
||||
}));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Commands
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
pi.registerCommand("help", {
|
||||
description: "Show all EasyMode commands",
|
||||
handler: async (_args, ctx) => {
|
||||
ctx.ui.notify(
|
||||
"📖 EasyMode Commands:\n\n" +
|
||||
" /menu — 🟢 START HERE — pick a command from a list\n" +
|
||||
" /help — You're looking at it!\n" +
|
||||
" /agent — Switch AI personality (beginner, builder, reviewer...)\n" +
|
||||
" /goal — Set or change your session goal\n" +
|
||||
" /stop — Immediately stop the current AI action\n" +
|
||||
" /explain — Ask the AI to explain what it just did\n" +
|
||||
" /bookmark — Save the current moment with a note\n" +
|
||||
" /bookmarks — See all saved bookmarks\n" +
|
||||
" /undo — Show how to undo the last file change\n" +
|
||||
" /status — Show session overview (goal, agent, stats)\n" +
|
||||
" /quickstart — Re-run the setup wizard\n" +
|
||||
"\n💡 Tips:\n" +
|
||||
" • Just type what you want in plain English — no special syntax needed\n" +
|
||||
" • The bottom bar shows your context usage and cost\n" +
|
||||
" • ⚠️ Dangerous commands always ask for your OK first",
|
||||
"info"
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("menu", {
|
||||
description: "Pick a command from a visual menu",
|
||||
handler: async (_args, ctx) => {
|
||||
const menuItems = [
|
||||
"🎯 Set or change my goal",
|
||||
"🤖 Switch AI personality",
|
||||
"💡 Explain what just happened",
|
||||
"🔖 Save a bookmark",
|
||||
"📋 See all my bookmarks",
|
||||
"↩️ Undo the last file change",
|
||||
"📊 Show session status",
|
||||
"🛑 Stop the AI",
|
||||
"🔄 Re-run setup wizard",
|
||||
"📖 Show all commands (help)",
|
||||
];
|
||||
const choice = await ctx.ui.select("What would you like to do?", menuItems);
|
||||
if (choice === undefined) return;
|
||||
|
||||
const idx = menuItems.indexOf(choice);
|
||||
switch (idx) {
|
||||
case 0: { // Goal
|
||||
const answer = await ctx.ui.input("🎯 What's your goal?", purpose || "e.g. Fix the login bug...");
|
||||
if (answer && answer.trim()) {
|
||||
purpose = answer.trim();
|
||||
ctx.ui.notify(`🎯 Goal updated: ${purpose}`, "success");
|
||||
setPurposeWidget(ctx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 1: { // Agent
|
||||
const options = AGENT_PRESETS.map((p) => `${p.emoji} ${p.name} — ${p.description}`);
|
||||
const agentChoice = await ctx.ui.select("Select AI Personality", options);
|
||||
if (agentChoice !== undefined) {
|
||||
const agentIdx = options.indexOf(agentChoice);
|
||||
activePreset = AGENT_PRESETS[agentIdx];
|
||||
ctx.ui.setStatus("easymode", `${activePreset.emoji} ${activePreset.name}`);
|
||||
ctx.ui.notify(`Switched to: ${activePreset.emoji} ${activePreset.name}\n${activePreset.description}`, "success");
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 2: // Explain
|
||||
ctx.sendMessage("Please explain what you just did in simple terms that a beginner would understand. Use bullet points and avoid jargon. If you wrote code, explain what each part does.");
|
||||
return;
|
||||
case 3: { // Bookmark
|
||||
const note = await ctx.ui.input("🔖 Bookmark note", "e.g. Got login working, Before refactor...");
|
||||
if (note && note.trim()) {
|
||||
const ts = new Date().toLocaleTimeString();
|
||||
bookmarks.push({ timestamp: ts, note: note.trim() });
|
||||
pi.appendEntry("easymode-bookmarks", { timestamp: ts, note: note.trim() });
|
||||
ctx.ui.notify(`🔖 Saved: ${note.trim()} (${ts})`, "success");
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 4: { // Bookmarks
|
||||
if (bookmarks.length === 0) return ctx.ui.notify("No bookmarks yet. Use /bookmark to save one.", "info");
|
||||
const list = bookmarks.map((b, i) => ` ${i + 1}. [${b.timestamp}] ${b.note}`).join("\n");
|
||||
return ctx.ui.notify(`🔖 Bookmarks:\n\n${list}`, "info");
|
||||
}
|
||||
case 5: { // Undo
|
||||
if (!lastFileChange) {
|
||||
return ctx.ui.notify("No file changes tracked yet.\n\n💡 You can also tell the AI: \"Please undo the last change\"", "info");
|
||||
}
|
||||
return ctx.ui.notify(
|
||||
`↩️ Last change: ${lastFileChange.tool} → ${lastFileChange.path}\n\n` +
|
||||
'Tell the AI: "Please revert the last change"\n' +
|
||||
`Or run: git checkout -- ${lastFileChange.path}`,
|
||||
"info"
|
||||
);
|
||||
}
|
||||
case 6: { // Status
|
||||
const usage = ctx.getContextUsage();
|
||||
const pct = usage?.percent ?? 0;
|
||||
let tokIn = 0, tokOut = 0, cost = 0;
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type === "message" && entry.message.role === "assistant") {
|
||||
const m = entry.message as AssistantMessage;
|
||||
tokIn += m.usage.input;
|
||||
tokOut += m.usage.output;
|
||||
cost += m.usage.cost.total;
|
||||
}
|
||||
}
|
||||
return ctx.ui.notify(
|
||||
`📋 Session Status\n\n` +
|
||||
` 🎯 Goal: ${purpose || "(not set)"}\n` +
|
||||
` 🤖 Agent: ${activePreset.emoji} ${activePreset.name}\n` +
|
||||
` 📊 Context: ${Math.round(pct)}% used\n` +
|
||||
` 💰 Cost: $${cost.toFixed(4)}`,
|
||||
"info"
|
||||
);
|
||||
}
|
||||
case 7: // Stop
|
||||
ctx.abort();
|
||||
return ctx.ui.notify("🛑 Stopped.", "warning");
|
||||
case 8: // Quickstart
|
||||
await runWelcomeWizard(ctx);
|
||||
return;
|
||||
case 9: // Help
|
||||
return ctx.ui.notify(
|
||||
"📖 All commands start with / (slash):\n\n" +
|
||||
" /menu /goal /agent /explain /bookmark\n" +
|
||||
" /bookmarks /undo /status /stop /quickstart /help\n\n" +
|
||||
"Or just type what you want in plain English!",
|
||||
"info"
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("quickstart", {
|
||||
description: "Re-run the welcome wizard",
|
||||
handler: async (_args, ctx) => {
|
||||
await runWelcomeWizard(ctx);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("agent", {
|
||||
description: "Switch AI personality preset",
|
||||
handler: async (_args, ctx) => {
|
||||
const options = AGENT_PRESETS.map((p) => `${p.emoji} ${p.name} — ${p.description}`);
|
||||
const choice = await ctx.ui.select("Select AI Personality", options);
|
||||
if (choice === undefined) return;
|
||||
const idx = options.indexOf(choice);
|
||||
activePreset = AGENT_PRESETS[idx];
|
||||
ctx.ui.setStatus("easymode", `${activePreset.emoji} ${activePreset.name}`);
|
||||
ctx.ui.notify(`Switched to: ${activePreset.emoji} ${activePreset.name}\n${activePreset.description}`, "success");
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("goal", {
|
||||
description: "Set or change your session goal",
|
||||
handler: async (_args, ctx) => {
|
||||
const answer = await ctx.ui.input("🎯 What's your goal?", purpose || "e.g. Fix the login bug...");
|
||||
if (answer && answer.trim()) {
|
||||
purpose = answer.trim();
|
||||
ctx.ui.notify(`🎯 Goal updated: ${purpose}`, "success");
|
||||
setPurposeWidget(ctx);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("stop", {
|
||||
description: "Immediately stop the current AI action",
|
||||
handler: async (args, ctx) => {
|
||||
const reason = (args || "").trim();
|
||||
ctx.abort();
|
||||
ctx.ui.notify(reason ? `🛑 Stopped: ${reason}` : "🛑 Stopped.", "warning");
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("explain", {
|
||||
description: "Ask the AI to explain its last action in simple terms",
|
||||
handler: async (_args, ctx) => {
|
||||
ctx.ui.notify("💡 Asking AI to explain...", "info");
|
||||
ctx.sendMessage(
|
||||
"Please explain what you just did in simple terms that a beginner would understand. " +
|
||||
"Use bullet points and avoid jargon. If you wrote code, explain what each part does."
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("bookmark", {
|
||||
description: "Save a bookmark with a note",
|
||||
handler: async (_args, ctx) => {
|
||||
const note = await ctx.ui.input("🔖 Bookmark note", "e.g. Got login working, Before refactor...");
|
||||
if (note && note.trim()) {
|
||||
const ts = new Date().toLocaleTimeString();
|
||||
bookmarks.push({ timestamp: ts, note: note.trim() });
|
||||
pi.appendEntry("easymode-bookmarks", { timestamp: ts, note: note.trim() });
|
||||
ctx.ui.notify(`🔖 Saved: ${note.trim()} (${ts})`, "success");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("bookmarks", {
|
||||
description: "Show all saved bookmarks",
|
||||
handler: async (_args, ctx) => {
|
||||
if (bookmarks.length === 0) {
|
||||
ctx.ui.notify("No bookmarks yet. Use /bookmark to save one.", "info");
|
||||
return;
|
||||
}
|
||||
const list = bookmarks.map((b, i) => ` ${i + 1}. [${b.timestamp}] ${b.note}`).join("\n");
|
||||
ctx.ui.notify(`🔖 Bookmarks:\n\n${list}`, "info");
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("undo", {
|
||||
description: "Show how to undo the last file change",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!lastFileChange) {
|
||||
ctx.ui.notify(
|
||||
"No file changes tracked yet.\n\n" +
|
||||
"💡 General undo:\n" +
|
||||
" git checkout -- <file> revert to last commit\n" +
|
||||
" git diff see what changed\n" +
|
||||
" git stash save & revert everything",
|
||||
"info"
|
||||
);
|
||||
return;
|
||||
}
|
||||
ctx.ui.notify(
|
||||
`↩️ Last change: ${lastFileChange.tool} → ${lastFileChange.path}\n\n` +
|
||||
"To undo:\n" +
|
||||
` git checkout -- ${lastFileChange.path}\n\n` +
|
||||
"See diff:\n" +
|
||||
` git diff ${lastFileChange.path}\n\n` +
|
||||
'Or tell the AI: "Please revert the last change"',
|
||||
"info"
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("status", {
|
||||
description: "Show session overview",
|
||||
handler: async (_args, ctx) => {
|
||||
const usage = ctx.getContextUsage();
|
||||
const pct = usage?.percent ?? 0;
|
||||
let tokIn = 0, tokOut = 0, cost = 0;
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type === "message" && entry.message.role === "assistant") {
|
||||
const m = entry.message as AssistantMessage;
|
||||
tokIn += m.usage.input;
|
||||
tokOut += m.usage.output;
|
||||
cost += m.usage.cost.total;
|
||||
}
|
||||
}
|
||||
const entries = Object.entries(toolCounts);
|
||||
const toolSummary = entries.length === 0
|
||||
? " None yet"
|
||||
: entries.map(([n, c]) => ` ${n}: ${c}`).join("\n");
|
||||
|
||||
ctx.ui.notify(
|
||||
`📋 Session Status\n\n` +
|
||||
` 🎯 Goal: ${purpose || "(not set — use /goal)"}\n` +
|
||||
` 🤖 Agent: ${activePreset.emoji} ${activePreset.name}\n` +
|
||||
` 📊 Context: ${Math.round(pct)}% used\n` +
|
||||
` 💰 Cost: $${cost.toFixed(4)}\n` +
|
||||
` 📨 Tokens: ${tokIn} in / ${tokOut} out\n` +
|
||||
` 🔖 Bookmarks: ${bookmarks.length}\n` +
|
||||
`\n🔧 Tools Used:\n${toolSummary}`,
|
||||
"info"
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export const THEME_MAP: Record<string, string> = {
|
||||
"tilldone": "everforest", // task-focused calm
|
||||
"tool-counter": "synthwave", // techy metrics
|
||||
"tool-counter-widget":"synthwave", // same family
|
||||
"easymode": "catppuccin-mocha", // soft, welcoming for beginners
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
Submodule
+1
Submodule giveaway/pi-easymode added at 419f9a7cfa
@@ -83,6 +83,16 @@ ext-session-replay:
|
||||
ext-theme-cycler:
|
||||
pi -e extensions/theme-cycler.ts -e extensions/minimal.ts
|
||||
|
||||
# beginner
|
||||
|
||||
# 19. EasyMode: all-in-one beginner-friendly agent with welcome wizard, /menu, safety guards, goals, agent presets
|
||||
ext-easymode:
|
||||
pi -e extensions/easymode.ts
|
||||
|
||||
# 20. EasyMode + Theme Cycling
|
||||
ext-easymode-themed:
|
||||
pi -e extensions/easymode.ts -e extensions/theme-cycler.ts
|
||||
|
||||
# utils
|
||||
|
||||
# Open pi with one or more stacked extensions in a new terminal: just open minimal tool-counter
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+133
-4
@@ -1,5 +1,134 @@
|
||||
🚀 Bot starting...
|
||||
✅ @cr_management_smart_bot is live
|
||||
📤 [775071081] reply length: 504
|
||||
🔧 [775071081] run_command: top -bn1 | head -20
|
||||
📤 [775071081] reply length: 585
|
||||
✅ Bot is live (concurrent mode)
|
||||
📤 [775071081] 301 chars in 5.8s
|
||||
📤 [775071081] 480 chars in 5.7s
|
||||
📤 [775071081] 116 chars in 3.0s
|
||||
🔧 [775071081] uname -a
|
||||
📤 [775071081] 212 chars in 7.6s
|
||||
🔧 [775071081] systemctl list-units --type=service --state=running | head -20
|
||||
🔧 [775071081] ps aux | head -20
|
||||
🔧 [775071081] ps aux | grep -v "^\[" | grep -E "(nginx|apache|haproxy|mysql|redis|postgres|docker|incus)" | head -10
|
||||
🔧 [775071081] free -h && echo && df -h | head -5
|
||||
📤 [775071081] 420 chars in 24.9s
|
||||
🔧 [775071081] incus list
|
||||
📤 [775071081] 379 chars in 9.7s
|
||||
🔧 [775071081] cd /opt/enthuse-db-sync-v2 && ls -la
|
||||
🔧 [775071081] cd /opt/enthuse-db-sync-v2 && docker ps | grep enthuse
|
||||
🔧 [775071081] cd /opt/enthuse-db-sync-v2 && tail -20 logs/*.log | head -20
|
||||
🔧 [775071081] cd /opt/enthuse-db-sync-v2 && ls logs/ | tail -5
|
||||
🔧 [775071081] cd /opt/enthuse-db-sync-v2 && tail -10 logs/sync_20260227_185400.log
|
||||
🚀 Bot starting...
|
||||
✅ Bot is live (concurrent mode)
|
||||
🔧 [775071081] cd /opt/enthuse-db-sync-v2 && docker-compose ps
|
||||
🔧 [775071081] cd /opt/enthuse-db-sync-v2 && ls -la
|
||||
🔧 [775071081] cd /opt/enthuse-db-sync-v2 && docker ps | grep enthuse
|
||||
🔧 [775071081] systemctl status docker
|
||||
🔧 [775071081] ps aux | grep -E "(enthuse|launchgood)" | head -10
|
||||
🔧 [775071081] cd /opt/launchgood-sync && ls -la
|
||||
🔧 [775071081] crontab -l | grep -E "(enthuse|launchgood)"
|
||||
🔧 [775071081] cd /opt/enthuse-db-sync-v2/logs && ls -la | tail -5
|
||||
🔧 [775071081] cd /opt/launchgood-sync/logs && ls -la | tail -5
|
||||
🔧 [775071081] tail -n 20 /opt/enthuse-db-sync-v2/logs/sync_20260227_185400.log
|
||||
📤 [775071081] 83 chars in 132.2s
|
||||
🚀 Bot starting...
|
||||
✅ Bot is live (concurrent mode)
|
||||
🔧 [775071081] systemctl status enthuse-db-sync-v2 | head -10
|
||||
🔧 [775071081] ps aux | grep -E "(enthuse|launchgood)" | grep -v grep
|
||||
🔧 [775071081] ls -la /opt/ | grep -E "(enthuse|launch)"
|
||||
🔧 [775071081] cd /opt/enthuse-db-sync-v2 && ls -la | head -5
|
||||
🔧 [775071081] cd /opt/enthuse-db-sync-v2 && find . -name "*.log" | head -3
|
||||
🔧 [775071081] cd /opt/enthuse-db-sync-v2 && ls -lt logs/ | head -5
|
||||
🔧 [775071081] cd /opt/enthuse-db-sync-v2 && tail -10 logs/sync_20260227_185400.log
|
||||
🔧 [775071081] cd /opt/launchgood-sync && ls -lt logs/ | head -5
|
||||
🔧 [775071081] cd /opt/launchgood-sync && tail -5 logs/donations_20260227_185411.log
|
||||
🔧 [775071081] crontab -l | grep -E "(enthuse|launch)"
|
||||
🚀 Bot starting...
|
||||
✅ Bot is live (concurrent mode)
|
||||
🚀 Bot starting...
|
||||
✅ Bot is live (concurrent mode)
|
||||
🔧 [775071081] incus exec cr-server-new -- ps aux | grep -E "(enthuse|launchgood|charityright)" | grep -v grep
|
||||
🔧 [775071081] incus exec cr-server-new -- ls -la /opt/ | grep sync
|
||||
🔧 [775071081] incus exec cr-server-new -- crontab -l | grep sync
|
||||
🔧 [775071081] incus exec cr-server-new -- tail -5 /opt/enthuse-db-sync-v2/logs/cron.log
|
||||
🔧 [775071081] incus exec cr-server-new -- tail -5 /opt/launchgood-sync/logs/cron.log
|
||||
🔧 [775071081] incus exec cr-server-new -- tail -5 /opt/charityright-sync/logs/cron.log
|
||||
📤 [775071081] 361 chars in 34.9s
|
||||
🔧 [775071081] incus exec cr-server-new -- tail -20 /opt/charityright-sync/logs/cron.log
|
||||
🔧 [775071081] incus exec cr-server-new -- ls -la /opt/charityright-sync/logs/ | head -10
|
||||
🔧 [775071081] incus exec cr-server-new -- tail -50 /opt/charityright-sync/logs/sync_$(ls /opt/charityright-sync/logs/sync_*.log | tail -1 | cut -d'/' -f5)
|
||||
🔧 [775071081] incus exec cr-server-new -- ls /opt/charityright-sync/logs/sync_*.log | tail -1
|
||||
🔧 [775071081] incus exec cr-server-new -- find /opt/charityright-sync/logs/ -name "sync_*.log" -type f | tail -1
|
||||
🔧 [775071081] incus exec cr-server-new -- tail -20 /opt/charityright-sync/logs/sync_20260303_220001.log
|
||||
🔧 [775071081] incus exec cr-server-new -- bash /opt/charityright-sync/run-sync.sh
|
||||
🔧 [775071081] incus exec cr-server-new -- cat /opt/charityright-sync/run-sync.sh | head -20
|
||||
🔧 [775071081] incus exec cr-server-new -- grep -A 10 -B 10 "FAILED.*exit: 0" /opt/charityright-sync/run-sync.sh
|
||||
🔧 [775071081] incus exec cr-server-new -- bash -c "cd /opt/charityright-sync && timeout 60 python3 sync.py" 2>&1 | head -20
|
||||
🚀 Bot starting...
|
||||
✅ Bot is live (concurrent mode)
|
||||
🔧 [775071081] incus list
|
||||
🔧 [775071081] incus exec cr-server-new -- docker ps -a | head -10
|
||||
🔧 [775071081] systemctl status haproxy | head -5
|
||||
🔧 [775071081] service haproxy status | head -5
|
||||
🔧 [775071081] ps aux | grep haproxy | grep -v grep
|
||||
🔧 [775071081] incus exec cr-server-new -- ps aux | grep -E "(sync|cron)" | head -5
|
||||
📤 [775071081] 394 chars in 89.3s
|
||||
🔧 [775071081] incus exec cr-server-new -- crontab -l | grep -E "(enthuse|launchgood|charityright)" | head -10
|
||||
🔧 [775071081] incus exec cr-server-new -- tail -5 /opt/enthuse-db-sync-v2/logs/cron.log
|
||||
🔧 [775071081] incus exec cr-server-new -- tail -5 /opt/launchgood-sync/logs/cron.log
|
||||
🔧 [775071081] incus exec cr-server-new -- tail -5 /opt/charityright-sync/logs/cron.log
|
||||
🔧 [775071081] incus exec cr-server-new -- docker exec dokploy-migrated-cr-postgres-data psql -U postgres -d donation_warehouse -c "SELECT COUNT(*) as total_donations FROM donations;" 2>/dev/null
|
||||
🔧 [775071081] incus exec cr-server-new -- docker ps | grep postgres
|
||||
🔧 [775071081] incus exec cr-server-new -- docker exec dokploy-migrated-cr-postgres-data psql -U postgres -d donation_warehouse -c "SELECT COUNT(*) FROM donations;" | head -5
|
||||
🔧 [775071081] incus exec cr-server-new -- docker exec dokploy-migrated-cr-postgres-data psql -U postgres -l | grep donation
|
||||
🔧 [775071081] incus exec cr-server-new -- docker exec dokploy-migrated-cr-postgres-data psql -U postgres -d donation_warehouse -c "\dt" | head -5
|
||||
📤 [775071081] 558 chars in 237.2s
|
||||
🔧 [775071081] incus exec cr-server-new -- cat /opt/charityright-sync/run-sync.sh
|
||||
🔧 [775071081] incus exec cr-server-new -- tail -20 /opt/charityright-sync/run-sync.sh
|
||||
🔧 [775071081] incus exec cr-server-new -- ls -la /opt/charityright-sync/logs/ | head -5
|
||||
🔧 [775071081] incus exec cr-server-new -- ls -la /opt/charityright-sync/logs/sync_* | tail -3
|
||||
🔧 [775071081] incus exec cr-server-new -- find /opt/charityright-sync/logs/ -name "sync_*" -type f | wc -l
|
||||
🔧 [775071081] incus exec cr-server-new -- find /opt/charityright-sync/logs/ -name "sync_*" -type f | sort -t_ -k2 | tail -3
|
||||
🔧 [775071081] incus exec cr-server-new -- cat /opt/charityright-sync/logs/sync_20260304_160001.log
|
||||
🔧 [775071081] incus exec cr-server-new -- docker images | grep charityright
|
||||
🔧 [775071081] incus exec cr-server-new -- ls -la /opt/charityright-sync/
|
||||
🔧 [775071081] incus exec cr-server-new -- cat /opt/charityright-sync/Dockerfile
|
||||
🔧 [775071081] incus exec cr-server-new -- cd /opt/charityright-sync && docker build -t charityright-sync .
|
||||
🔧 [775071081] incus exec cr-server-new -- bash -c "cd /opt/charityright-sync && docker build -t charityright-sync ."
|
||||
🔧 [775071081] incus exec cr-server-new -- docker images | grep charityright
|
||||
📤 [775071081] 155 chars in 29.8s
|
||||
🚀 Bot starting...
|
||||
✅ Bot is live (concurrent mode)
|
||||
🚀 Bot starting...
|
||||
✅ Bot is live (concurrent mode)
|
||||
🔧 [775071081] incus exec cr-server-new -- ls -la /opt/charityright-sync/
|
||||
🔧 [775071081] incus exec cr-server-new -- tail -20 /opt/charityright-sync/logs/sync.log
|
||||
🔧 [775071081] incus exec cr-server-new -- ls -la /opt/charityright-sync/logs/
|
||||
🔧 [775071081] incus exec cr-server-new -- tail -10 /opt/charityright-sync/logs/cron.log
|
||||
🔧 [775071081] incus exec cr-server-new -- tail -20 /opt/charityright-sync/logs/sync_20260303_173815.log
|
||||
🔧 [775071081] incus exec cr-server-new -- cat /opt/charityright-sync/run-sync.sh | head -30
|
||||
🔧 [775071081] incus exec cr-server-new -- tail -20 /opt/charityright-sync/run-sync.sh
|
||||
🔧 [775071081] incus exec cr-server-new -- docker run --rm --name charityright-sync-test --network dokploy-network charityright-sync 2>&1 | tail -10
|
||||
🔧 [775071081] incus exec cr-server-new -- timeout 30s docker run --rm --name charityright-sync-test --network dokploy-network charityright-sync; echo "Exit code: $?"
|
||||
🔧 [775071081] incus exec cr-server-new -- docker ps | grep charityright
|
||||
📤 [775071081] 647 chars in 220.2s
|
||||
🔧 [775071081] incus exec cr-server-new -- tail -20 /opt/charityright-sync/sync_charityright.py
|
||||
🔧 [775071081] incus exec cr-server-new -- grep -A5 -B5 "mysql_conn" /opt/charityright-sync/sync_charityright.py | tail -15
|
||||
🔧 [775071081] incus exec cr-server-new -- cp /opt/charityright-sync/sync_charityright.py /opt/charityright-sync/sync_charityright.py.backup
|
||||
🔧 [775071081] incus exec cr-server-new -- sed -i 's/if __name__ == '\''__main__'\'':/ import sys\n sys.exit(0)\n\nif __name__ == '\''__main__'\'':/' /opt/charityright-sync/sync_charityright.py
|
||||
🔧 [775071081] incus exec cr-server-new -- tail -10 /opt/charityright-sync/sync_charityright.py
|
||||
🔧 [775071081] incus exec cr-server-new -- cd /opt/charityright-sync && docker build -t charityright-sync .
|
||||
🔧 [775071081] incus exec cr-server-new -- bash -c "cd /opt/charityright-sync && docker build -t charityright-sync ."
|
||||
🔧 [775071081] incus exec cr-server-new -- timeout 60s docker run --rm --name charityright-sync-test --network dokploy-network charityright-sync; echo "Exit code: $?"
|
||||
🔧 [775071081] incus exec cr-server-new -- timeout 120s docker run --rm --name charityright-sync-test2 --network dokploy-network charityright-sync 2>&1 | tail -5; echo "Exit code: $?"
|
||||
🔧 [775071081] incus exec cr-server-new -- bash /opt/charityright-sync/run-sync.sh 2>&1 | tail -10
|
||||
📤 [775071081] 595 chars in 407.1s
|
||||
🔧 [775071081] incus exec cr-server-new -- bash /opt/charityright-sync/run-sync.sh
|
||||
🔧 [775071081] incus exec cr-server-new -- docker exec dokploy-migrated-cr-postgres-data psql -U postgres -d donation_warehouse -c "SELECT state, started_at FROM sync_state WHERE source='charityright' ORDER BY started_at DESC LIMIT 1"
|
||||
🔧 [775071081] incus exec cr-server-new -- docker exec dokploy-migrated-cr-postgres-data psql -U postgres -d donation_warehouse -c "SELECT * FROM sync_state WHERE source='charityright' ORDER BY started_at DESC LIMIT 1"
|
||||
🔧 [775071081] incus exec cr-server-new -- docker exec dokploy-migrated-cr-postgres-data psql -U postgres -d donation_warehouse -c "\d sync_state"
|
||||
🔧 [775071081] incus exec cr-server-new -- docker exec dokploy-migrated-cr-postgres-data psql -U postgres -d donation_warehouse -c "SELECT * FROM sync_state WHERE key='charityright'"
|
||||
🔧 [775071081] incus exec cr-server-new -- docker ps | grep charityright
|
||||
🔧 [775071081] incus exec cr-server-new -- bash -c "cd /opt/charityright-sync && timeout 180s docker run --rm --network dokploy-network charityright-sync 2>&1 | tail -10"
|
||||
🔧 [775071081] incus exec cr-server-new -- docker exec dokploy-migrated-cr-postgres-data psql -U postgres -d donation_warehouse -c "SELECT * FROM sync_state WHERE key='charityright'"
|
||||
🔧 [775071081] incus exec cr-server-new -- docker exec dokploy-migrated-cr-postgres-data psql -U postgres -d donation_warehouse -c "SELECT * FROM sync_state"
|
||||
📤 [775071081] 651 chars in 370.7s
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"name": "telegram-bot",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.78.0",
|
||||
"@grammyjs/runner": "^2.0.3",
|
||||
"grammy": "^1.41.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -21,6 +22,8 @@
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
|
||||
|
||||
"@grammyjs/runner": ["@grammyjs/runner@2.0.3", "", { "dependencies": { "abort-controller": "^3.0.0" }, "peerDependencies": { "grammy": "^1.13.1" } }, "sha512-nckmTs1dPWfVQteK9cxqxzE+0m1VRvluLWB8UgFzsjg62w3qthPJt0TYtJBEdG7OedvfQq4vnFAyE6iaMkR42A=="],
|
||||
|
||||
"@grammyjs/types": ["@grammyjs/types@3.25.0", "", {}, "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||
|
||||
+320
-101
@@ -1,4 +1,5 @@
|
||||
import { Bot, Context } from "grammy";
|
||||
import { run } from "@grammyjs/runner";
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
@@ -29,19 +30,83 @@ const anthropic = new Anthropic({ apiKey: ANTHROPIC_KEY });
|
||||
|
||||
// Per-user conversation history
|
||||
const conversations = new Map<number, Anthropic.MessageParam[]>();
|
||||
const MAX_HISTORY = 30;
|
||||
const MAX_HISTORY = 30; // raw entries before compression triggers
|
||||
const MAX_TOKENS_ESTIMATE = 12000; // compress when estimated tokens exceed this
|
||||
|
||||
// ─── Auth middleware ───
|
||||
// Per-user abort controllers — cancel stale requests
|
||||
const activeRequests = new Map<number, AbortController>();
|
||||
|
||||
// ─── Context compression ───
|
||||
function estimateTokens(messages: Anthropic.MessageParam[]): number {
|
||||
let chars = 0;
|
||||
for (const msg of messages) {
|
||||
if (typeof msg.content === "string") {
|
||||
chars += msg.content.length;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if ("text" in block) chars += (block as any).text.length;
|
||||
else if ("content" in block) chars += String((block as any).content).length;
|
||||
else chars += 100; // tool_use blocks etc
|
||||
}
|
||||
}
|
||||
}
|
||||
return Math.ceil(chars / 4); // rough chars-to-tokens
|
||||
}
|
||||
|
||||
async function compressHistory(userId: number, history: Anthropic.MessageParam[]): Promise<void> {
|
||||
if (history.length < 10 || estimateTokens(history) < MAX_TOKENS_ESTIMATE) return;
|
||||
|
||||
// Keep last 6 messages intact (current conversation), compress the rest
|
||||
const toCompress = history.slice(0, -6);
|
||||
const toKeep = history.slice(-6);
|
||||
|
||||
// Build a text summary of old messages
|
||||
const summaryParts: string[] = [];
|
||||
for (const msg of toCompress) {
|
||||
if (typeof msg.content === "string") {
|
||||
const role = msg.role === "user" ? "User" : "Assistant";
|
||||
summaryParts.push(`${role}: ${msg.content.slice(0, 200)}`);
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if ("text" in block && (block as any).text) {
|
||||
const role = msg.role === "user" ? "User" : "Assistant";
|
||||
summaryParts.push(`${role}: ${(block as any).text.slice(0, 200)}`);
|
||||
} else if ("content" in block && (block as any).type === "tool_result") {
|
||||
summaryParts.push(`[cmd output: ${String((block as any).content).slice(0, 100)}]`);
|
||||
} else if ((block as any).type === "tool_use") {
|
||||
summaryParts.push(`[ran: ${JSON.stringify((block as any).input).slice(0, 100)}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const compressedSummary = summaryParts.join("\n").slice(0, 2000);
|
||||
|
||||
// Replace history with compressed summary + recent messages
|
||||
history.length = 0;
|
||||
history.push({
|
||||
role: "user",
|
||||
content: `[Previous conversation summary]\n${compressedSummary}\n[End summary — continue from here]`,
|
||||
});
|
||||
history.push({
|
||||
role: "assistant",
|
||||
content: "Understood, I have the context from our previous conversation. How can I help?",
|
||||
});
|
||||
history.push(...toKeep);
|
||||
|
||||
console.log(`📦 [${userId}] Compressed history: ${toCompress.length + toKeep.length} → ${history.length} entries (~${estimateTokens(history)} tokens)`);
|
||||
}
|
||||
|
||||
// ─── Auth ───
|
||||
function isAuthorized(ctx: Context): boolean {
|
||||
// Reject if no whitelist configured (fail-closed)
|
||||
if (ALLOWED_USERS.length === 0) return false;
|
||||
return ALLOWED_USERS.includes(ctx.from?.id || 0);
|
||||
}
|
||||
|
||||
// ─── Rate limiting ───
|
||||
const rateLimits = new Map<number, number[]>();
|
||||
const RATE_LIMIT_WINDOW = 60_000; // 1 minute
|
||||
const RATE_LIMIT_MAX = 15; // max messages per window
|
||||
const RATE_LIMIT_WINDOW = 60_000;
|
||||
const RATE_LIMIT_MAX = 15;
|
||||
|
||||
function isRateLimited(userId: number): boolean {
|
||||
const now = Date.now();
|
||||
@@ -51,22 +116,22 @@ function isRateLimited(userId: number): boolean {
|
||||
return timestamps.length > RATE_LIMIT_MAX;
|
||||
}
|
||||
|
||||
// ─── Blocked command patterns ───
|
||||
// ─── Blocked commands ───
|
||||
const BLOCKED_PATTERNS = [
|
||||
/rm\s+-rf\s+\/(?!\w)/i, // rm -rf /
|
||||
/mkfs\./i, // format disk
|
||||
/dd\s+if=.*of=\/dev/i, // overwrite disk
|
||||
/:\(\)\s*\{\s*:\|:/, // fork bomb
|
||||
/rm\s+-rf\s+\/(?!\w)/i,
|
||||
/mkfs\./i,
|
||||
/dd\s+if=.*of=\/dev/i,
|
||||
/:\(\)\s*\{\s*:\|:/,
|
||||
];
|
||||
|
||||
function isDangerous(cmd: string): string | null {
|
||||
for (const pattern of BLOCKED_PATTERNS) {
|
||||
if (pattern.test(cmd)) return `⛔ Blocked: matches dangerous pattern \`${pattern.source}\``;
|
||||
if (pattern.test(cmd)) return `⛔ Blocked dangerous pattern`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── SSH exec helper ───
|
||||
// ─── SSH exec ───
|
||||
async function sshExec(command: string, timeoutSec = 30): Promise<string> {
|
||||
const proc = Bun.spawn(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=accept-new",
|
||||
@@ -83,60 +148,109 @@ async function sshExec(command: string, timeoutSec = 30): Promise<string> {
|
||||
await proc.exited;
|
||||
|
||||
const output = (stdout + stderr).trim();
|
||||
return output.length > 3500 ? output.slice(0, 3500) + "\n...(truncated)" : output;
|
||||
return output.length > 2000 ? output.slice(0, 2000) + "\n...(truncated)" : output;
|
||||
}
|
||||
|
||||
// ─── Promise with timeout helper ───
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1000}s`)), ms)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── System prompt ───
|
||||
const SYSTEM_PROMPT = `You are a DevOps assistant connected to a live server via Telegram.
|
||||
You have access to run SSH commands on the server. You prioritize uptime.
|
||||
const SYSTEM_PROMPT = `You are a DevOps assistant on Telegram. Connected to a live server via SSH.
|
||||
|
||||
Infrastructure context:
|
||||
${infraContext}
|
||||
|
||||
ARCHITECTURE:
|
||||
- The bot runs on a LOCAL LAPTOP (macOS). It is NOT on the server.
|
||||
- run_command executes via SSH on the PRIMARY HOST (${SSH_HOST}).
|
||||
- The primary host runs HAProxy and Incus (LXC containers).
|
||||
- Docker runs INSIDE the Incus container "cr-server-new" (IP 10.213.16.224), NOT on the primary host.
|
||||
- To run Docker/sync commands, you MUST wrap with: incus exec cr-server-new -- <command>
|
||||
- Example: incus exec cr-server-new -- docker ps
|
||||
- Example: incus exec cr-server-new -- bash /opt/enthuse-db-sync-v2/run-sync.sh
|
||||
- To query Postgres: incus exec cr-server-new -- docker exec dokploy-migrated-cr-postgres-data psql -U postgres -d donation_warehouse -c "SELECT ..."
|
||||
- NEVER run docker commands directly on the primary host — they won't work.
|
||||
|
||||
KEY PATHS (inside cr-server-new):
|
||||
- /opt/enthuse-db-sync-v2/ — Enthuse sync
|
||||
- /opt/launchgood-sync/ — LaunchGood sync
|
||||
- /opt/charityright-sync/ — CharityRight sync
|
||||
- Postgres container: dokploy-migrated-cr-postgres-data
|
||||
- Docker network: dokploy-network
|
||||
|
||||
RULES:
|
||||
- If the user asks to run a command, use the run_command tool.
|
||||
- For destructive commands (rm -rf, DROP, reboot, etc.), WARN the user and ask for confirmation before executing.
|
||||
- Keep responses concise — this is Telegram, not a terminal.
|
||||
- You can chain multiple commands in one SSH call using && or ;
|
||||
- Format output with monospace blocks for readability.`;
|
||||
- Be VERY concise — this is Telegram, not a terminal.
|
||||
- Keep command output short. Use | head, | tail, grep to limit output.
|
||||
- For destructive commands, WARN and ask for confirmation first.
|
||||
- ALWAYS end your response with a text message to the user. Never end on a tool call with no text summary.`;
|
||||
|
||||
// ─── Claude tools ───
|
||||
const tools: Anthropic.Tool[] = [
|
||||
{
|
||||
name: "run_command",
|
||||
description: "Execute a command on the server via SSH. Use for any server inspection, management, or debugging task.",
|
||||
description: "Run a shell command on the server via SSH. Keep commands short and focused. Always pipe through head/tail/grep to limit output.",
|
||||
input_schema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
command: { type: "string", description: "Shell command to run on the server" },
|
||||
timeout: { type: "number", description: "Timeout in seconds (default 30)" },
|
||||
command: { type: "string", description: "Shell command" },
|
||||
},
|
||||
required: ["command"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Process message through Claude ───
|
||||
async function chat(userId: number, message: string): Promise<string> {
|
||||
// Get or init conversation history
|
||||
// ─── Chat with Claude — with cancellation + timeout ───
|
||||
// Global typing context so SSH exec can trigger typing too
|
||||
let activeCtx: Context | null = null;
|
||||
|
||||
async function chat(userId: number, message: string, signal: AbortSignal): Promise<string> {
|
||||
if (!conversations.has(userId)) conversations.set(userId, []);
|
||||
const history = conversations.get(userId)!;
|
||||
|
||||
// Snapshot history length before we start — rollback to here on error
|
||||
const historySnapshot = history.length;
|
||||
|
||||
history.push({ role: "user", content: message });
|
||||
|
||||
// Trim history if too long
|
||||
// Compress old history if getting too large
|
||||
await compressHistory(userId, history);
|
||||
while (history.length > MAX_HISTORY) history.shift();
|
||||
|
||||
let response = await anthropic.messages.create({
|
||||
model: "claude-sonnet-4-20250514",
|
||||
max_tokens: 1024,
|
||||
system: SYSTEM_PROMPT,
|
||||
tools,
|
||||
messages: history,
|
||||
});
|
||||
try {
|
||||
return await _chatInner(userId, history, signal);
|
||||
} catch (err) {
|
||||
// Rollback history to prevent corruption (dangling tool_use without tool_result)
|
||||
history.splice(historySnapshot);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function _chatInner(userId: number, history: Anthropic.MessageParam[], signal: AbortSignal): Promise<string> {
|
||||
let rounds = 0;
|
||||
const MAX_ROUNDS = 10;
|
||||
|
||||
let response = await withTimeout(
|
||||
anthropic.messages.create({
|
||||
model: "claude-sonnet-4-20250514",
|
||||
max_tokens: 1024,
|
||||
system: SYSTEM_PROMPT,
|
||||
tools,
|
||||
messages: history,
|
||||
}),
|
||||
90_000,
|
||||
"Claude API"
|
||||
);
|
||||
|
||||
while (response.stop_reason === "tool_use" && rounds < MAX_ROUNDS) {
|
||||
if (signal.aborted) throw new Error("Cancelled — new message received");
|
||||
rounds++;
|
||||
|
||||
// Tool use loop
|
||||
while (response.stop_reason === "tool_use") {
|
||||
const assistantContent = response.content;
|
||||
history.push({ role: "assistant", content: assistantContent });
|
||||
|
||||
@@ -144,15 +258,22 @@ async function chat(userId: number, message: string): Promise<string> {
|
||||
|
||||
for (const block of assistantContent) {
|
||||
if (block.type === "tool_use") {
|
||||
const input = block.input as { command: string; timeout?: number };
|
||||
console.log(`🔧 [${userId}] run_command: ${input.command}`);
|
||||
if (signal.aborted) throw new Error("Cancelled");
|
||||
const input = block.input as { command: string };
|
||||
console.log(`🔧 [${userId}] ${input.command}`);
|
||||
|
||||
const danger = isDangerous(input.command);
|
||||
if (danger) {
|
||||
toolResults.push({ type: "tool_result", tool_use_id: block.id, content: danger, is_error: true });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const output = await sshExec(input.command, input.timeout || 30);
|
||||
// Show what's running
|
||||
if (activeCtx) {
|
||||
const shortCmd = input.command.length > 80 ? input.command.slice(0, 80) + "..." : input.command;
|
||||
await activeCtx.reply(`⚙️ \`${shortCmd}\``, { parse_mode: "Markdown" }).catch(() => {});
|
||||
}
|
||||
const output = await sshExec(input.command);
|
||||
toolResults.push({ type: "tool_result", tool_use_id: block.id, content: output || "(no output)" });
|
||||
} catch (err: any) {
|
||||
toolResults.push({ type: "tool_result", tool_use_id: block.id, content: `ERROR: ${err.message}`, is_error: true });
|
||||
@@ -160,80 +281,134 @@ async function chat(userId: number, message: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
// Always push tool_results immediately after assistant tool_use — keeps history valid
|
||||
history.push({ role: "user", content: toolResults });
|
||||
|
||||
response = await anthropic.messages.create({
|
||||
model: "claude-sonnet-4-20250514",
|
||||
max_tokens: 1024,
|
||||
system: SYSTEM_PROMPT,
|
||||
tools,
|
||||
messages: history,
|
||||
});
|
||||
response = await withTimeout(
|
||||
anthropic.messages.create({
|
||||
model: "claude-sonnet-4-20250514",
|
||||
max_tokens: 1024,
|
||||
system: SYSTEM_PROMPT,
|
||||
tools,
|
||||
messages: history,
|
||||
}),
|
||||
90_000,
|
||||
"Claude API"
|
||||
);
|
||||
}
|
||||
|
||||
if (response.stop_reason === "tool_use") {
|
||||
// Max rounds — force summary
|
||||
const assistantContent = response.content;
|
||||
history.push({ role: "assistant", content: assistantContent });
|
||||
|
||||
const toolResults: Anthropic.ToolResultBlockParam[] = [];
|
||||
for (const block of assistantContent) {
|
||||
if (block.type === "tool_use") {
|
||||
toolResults.push({ type: "tool_result", tool_use_id: block.id, content: "Skipped — max rounds reached. Summarize what you have so far.", is_error: true });
|
||||
}
|
||||
}
|
||||
history.push({ role: "user", content: toolResults });
|
||||
|
||||
const finalResponse = await withTimeout(
|
||||
anthropic.messages.create({
|
||||
model: "claude-sonnet-4-20250514",
|
||||
max_tokens: 1024,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: history,
|
||||
}),
|
||||
60_000,
|
||||
"Claude summary"
|
||||
);
|
||||
|
||||
const text = finalResponse.content
|
||||
.filter((b): b is Anthropic.TextBlock => b.type === "text")
|
||||
.map((b) => b.text)
|
||||
.join("\n")
|
||||
.trim();
|
||||
history.push({ role: "assistant", content: text || "Done." });
|
||||
return text || "Done (max rounds reached).";
|
||||
}
|
||||
|
||||
// Extract text response
|
||||
const text = response.content
|
||||
.filter((b): b is Anthropic.TextBlock => b.type === "text")
|
||||
.map((b) => b.text)
|
||||
.join("\n");
|
||||
.join("\n")
|
||||
.trim();
|
||||
|
||||
if (!text) {
|
||||
// Claude gave no text — ask it to summarize
|
||||
history.push({ role: "assistant", content: response.content });
|
||||
history.push({ role: "user", content: "Now give me a brief summary of what you found. Text only, no tool calls." });
|
||||
const followUp = await withTimeout(
|
||||
anthropic.messages.create({
|
||||
model: "claude-sonnet-4-20250514",
|
||||
max_tokens: 1024,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: history,
|
||||
}),
|
||||
60_000,
|
||||
"Claude follow-up"
|
||||
);
|
||||
const followUpText = followUp.content
|
||||
.filter((b): b is Anthropic.TextBlock => b.type === "text")
|
||||
.map((b) => b.text)
|
||||
.join("\n")
|
||||
.trim();
|
||||
history.push({ role: "assistant", content: followUpText || "Done." });
|
||||
return followUpText || "Done.";
|
||||
}
|
||||
|
||||
history.push({ role: "assistant", content: text });
|
||||
|
||||
return text || "(no response)";
|
||||
return text;
|
||||
}
|
||||
|
||||
// ─── Security: block groups, unauthorized, rate-limited ───
|
||||
// ─── Security middleware ───
|
||||
bot.use(async (ctx, next) => {
|
||||
// Block group/channel messages — private only
|
||||
if (ctx.chat?.type !== "private") return;
|
||||
|
||||
// Silent block for unauthorized users — no response, no leak
|
||||
if (!isAuthorized(ctx)) {
|
||||
console.log(`🚫 Blocked user: ${ctx.from?.id} (@${ctx.from?.username})`);
|
||||
console.log(`🚫 Blocked: ${ctx.from?.id} (@${ctx.from?.username})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Rate limit
|
||||
if (ctx.from && isRateLimited(ctx.from.id)) {
|
||||
return ctx.reply("⏳ Slow down — rate limited.");
|
||||
return ctx.reply("⏳ Slow down.");
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
// ─── Bot handlers ───
|
||||
bot.command("start", (ctx) => {
|
||||
return ctx.reply(
|
||||
"🤖 *CR Management Bot*\n\n" +
|
||||
"I'm connected to your server. You can:\n" +
|
||||
"• Ask me anything about your infra\n" +
|
||||
"• Ask me to run commands\n" +
|
||||
"• `/status` — quick server health\n" +
|
||||
"• `/clear` — reset conversation\n" +
|
||||
"• `/ssh <command>` — run a command directly\n",
|
||||
{ parse_mode: "Markdown" }
|
||||
);
|
||||
});
|
||||
// ─── Commands ───
|
||||
bot.command("start", (ctx) =>
|
||||
ctx.reply(
|
||||
"🤖 CR Management Bot\n\n" +
|
||||
"• Chat naturally — I understand your infra\n" +
|
||||
"• /status — server health\n" +
|
||||
"• /ssh <cmd> — run command directly\n" +
|
||||
"• /cancel — stop current task\n" +
|
||||
"• /clear — reset conversation"
|
||||
)
|
||||
);
|
||||
|
||||
bot.command("status", async (ctx) => {
|
||||
await ctx.reply("🔍 Checking server...");
|
||||
await ctx.reply("🔍 Checking...");
|
||||
try {
|
||||
const output = await sshExec("uptime && echo '---' && df -h / && echo '---' && free -h | head -2 && echo '---' && incus list --format csv -c ns 2>/dev/null");
|
||||
return ctx.reply(`\`\`\`\n${output}\n\`\`\``, { parse_mode: "Markdown" });
|
||||
const output = await withTimeout(
|
||||
sshExec("uptime && echo --- && df -h / | tail -1 && echo --- && free -h | grep Mem && echo --- && incus list -f csv -c ns 2>/dev/null", 10),
|
||||
15_000, "SSH"
|
||||
);
|
||||
return ctx.reply(output);
|
||||
} catch (err: any) {
|
||||
return ctx.reply(`❌ SSH failed: ${err.message}`);
|
||||
return ctx.reply(`❌ ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
bot.command("ssh", async (ctx) => {
|
||||
const cmd = ctx.match;
|
||||
if (!cmd) return ctx.reply("Usage: `/ssh <command>`", { parse_mode: "Markdown" });
|
||||
if (!cmd) return ctx.reply("Usage: /ssh <command>");
|
||||
const danger = isDangerous(cmd);
|
||||
if (danger) return ctx.reply(danger);
|
||||
await ctx.reply(`⚡ Running: \`${cmd}\``, { parse_mode: "Markdown" });
|
||||
try {
|
||||
const output = await sshExec(cmd);
|
||||
return ctx.reply(`\`\`\`\n${output || "(no output)"}\n\`\`\``, { parse_mode: "Markdown" });
|
||||
const output = await withTimeout(sshExec(cmd, 10), 15_000, "SSH");
|
||||
return ctx.reply(output || "(no output)");
|
||||
} catch (err: any) {
|
||||
return ctx.reply(`❌ ${err.message}`);
|
||||
}
|
||||
@@ -241,37 +416,81 @@ bot.command("ssh", async (ctx) => {
|
||||
|
||||
bot.command("clear", (ctx) => {
|
||||
conversations.delete(ctx.from!.id);
|
||||
return ctx.reply("🧹 Conversation cleared.");
|
||||
activeRequests.get(ctx.from!.id)?.abort();
|
||||
return ctx.reply("🧹 Cleared.");
|
||||
});
|
||||
|
||||
// Free text → Claude
|
||||
bot.on("message:text", async (ctx) => {
|
||||
bot.command("cancel", (ctx) => {
|
||||
const controller = activeRequests.get(ctx.from!.id);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
activeRequests.delete(ctx.from!.id);
|
||||
return ctx.reply("🛑 Cancelled.");
|
||||
}
|
||||
return ctx.reply("Nothing running.");
|
||||
});
|
||||
|
||||
// ─── Free text → Claude (fire-and-forget, never blocks bot) ───
|
||||
bot.on("message:text", (ctx) => {
|
||||
const userId = ctx.from!.id;
|
||||
const msg = ctx.message!.text;
|
||||
|
||||
await ctx.replyWithChatAction("typing");
|
||||
// Cancel any in-flight request for this user
|
||||
const prev = activeRequests.get(userId);
|
||||
if (prev) {
|
||||
prev.abort();
|
||||
// Clear corrupted history to avoid tool_result mismatch
|
||||
conversations.delete(userId);
|
||||
console.log(`⏹️ [${userId}] cancelled previous + cleared history`);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
activeRequests.set(userId, controller);
|
||||
|
||||
// Fire and forget — bot handler returns immediately
|
||||
processMessage(ctx, userId, msg, controller);
|
||||
});
|
||||
|
||||
async function processMessage(ctx: Context, userId: number, msg: string, controller: AbortController) {
|
||||
const startTime = Date.now();
|
||||
activeCtx = ctx;
|
||||
|
||||
// Keep typing every 3s — Telegram typing indicator expires after ~5s
|
||||
const typingInterval = setInterval(async () => {
|
||||
if (controller.signal.aborted) return;
|
||||
ctx.replyWithChatAction("typing").catch(() => {});
|
||||
}, 3000);
|
||||
ctx.replyWithChatAction("typing").catch(() => {});
|
||||
|
||||
try {
|
||||
const reply = await chat(userId, msg);
|
||||
console.log(`📤 [${userId}] reply length: ${reply.length}`);
|
||||
// Split long messages (Telegram 4096 char limit)
|
||||
const reply = await chat(userId, msg, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.log(`📤 [${userId}] ${reply.length} chars in ${elapsed}s`);
|
||||
|
||||
const chunks = reply.match(/[\s\S]{1,4000}/g) || ["(empty)"];
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
await ctx.reply(chunk);
|
||||
} catch (sendErr: any) {
|
||||
console.error(`❌ [${userId}] send failed:`, sendErr.message);
|
||||
await ctx.reply("⚠️ Failed to format response. Check logs.");
|
||||
}
|
||||
await ctx.reply(chunk).catch(() => ctx.reply("⚠️ Send failed."));
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`❌ [${userId}]`, err);
|
||||
await ctx.reply(`❌ Error: ${err.message}`);
|
||||
if (!controller.signal.aborted) {
|
||||
console.error(`❌ [${userId}]`, err.message);
|
||||
await ctx.reply(`❌ ${err.message}`).catch(() => {});
|
||||
}
|
||||
} finally {
|
||||
clearInterval(typingInterval);
|
||||
activeCtx = null;
|
||||
if (activeRequests.get(userId) === controller) activeRequests.delete(userId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Launch ───
|
||||
// ─── Launch with concurrent runner (never blocks on slow handlers) ───
|
||||
console.log("🚀 Bot starting...");
|
||||
bot.start({
|
||||
onStart: (info) => console.log(`✅ @${info.username} is live`),
|
||||
});
|
||||
const runner = run(bot);
|
||||
console.log(`✅ Bot is live (concurrent mode)`);
|
||||
|
||||
// Graceful shutdown
|
||||
const stop = () => { runner.isRunning() && runner.stop(); };
|
||||
process.on("SIGINT", stop);
|
||||
process.on("SIGTERM", stop);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.78.0",
|
||||
"@grammyjs/runner": "^2.0.3",
|
||||
"grammy": "^1.41.0"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user