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
+320 -101
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({
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);