update extensions, telegram bot, and add giveaway module

This commit is contained in:
Azreen Jamal
2026-03-05 18:11:32 +08:00
parent a7413075c3
commit bcce1d4549
9 changed files with 2674 additions and 105 deletions
+641
View File
@@ -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"
);
},
});
}
+1
View File
@@ -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 giveaway/pi-easymode added at 419f9a7cfa
+10
View File
@@ -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
+1564
View File
File diff suppressed because it is too large Load Diff
+133 -4
View File
@@ -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
+3
View File
@@ -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=="],
+310 -91
View File
@@ -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({
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({
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);
+1
View File
@@ -11,6 +11,7 @@
},
"dependencies": {
"@anthropic-ai/sdk": "^0.78.0",
"@grammyjs/runner": "^2.0.3",
"grammy": "^1.41.0"
}
}