telegram bot: two-way Claude chat + SSH command runner
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
@@ -0,0 +1,31 @@
|
||||
# CR Management Telegram Bot
|
||||
|
||||
AI-powered server management bot via Telegram (@cr_management_smart_bot).
|
||||
|
||||
## Features
|
||||
- **Two-way chat** — talk to Claude, it understands your infra
|
||||
- **Command runner** — execute SSH commands on your server
|
||||
- `/status` — quick server health check
|
||||
- `/ssh <cmd>` — run a command directly
|
||||
- `/clear` — reset conversation history
|
||||
|
||||
## Run locally
|
||||
```bash
|
||||
cd telegram-bot
|
||||
source ../.env
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
## Deploy to server
|
||||
```bash
|
||||
# Copy bot to server
|
||||
rsync -avz telegram-bot/ root@159.195.60.33:/opt/telegram-bot/
|
||||
scp .env root@159.195.60.33:/opt/telegram-bot/.env
|
||||
|
||||
# On server: install bun, then
|
||||
cd /opt/telegram-bot && bun install && bun run index.ts
|
||||
```
|
||||
|
||||
## Security
|
||||
- Set `TELEGRAM_ALLOWED_USERS` in `.env` to restrict access (comma-separated Telegram user IDs)
|
||||
- Empty = open to anyone (not recommended for production)
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "telegram-bot",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.78.0",
|
||||
"grammy": "^1.41.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.78.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
|
||||
|
||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
||||
|
||||
"grammy": ["grammy@1.41.0", "", { "dependencies": { "@grammyjs/types": "3.25.0", "abort-controller": "^3.0.0", "debug": "^4.4.3", "node-fetch": "^2.7.0" } }, "sha512-CAAu74SLT+/QCg40FBhUuYJalVsxxCN3D0c31TzhFBsWWTdXrMXYjGsKngBdfvN6hQ/VzHczluj/ugZVetFNCQ=="],
|
||||
|
||||
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||
|
||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||
|
||||
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { Bot, Context } from "grammy";
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
// ─── Config ───
|
||||
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN!;
|
||||
const ANTHROPIC_KEY = process.env.ANTHROPIC_API_KEY!;
|
||||
const SSH_USER = process.env.SSH_USER || "root";
|
||||
const SSH_HOST = process.env.SSH_HOST!;
|
||||
const SSH_PORT = process.env.SSH_PORT || "22";
|
||||
const ALLOWED_USERS = process.env.TELEGRAM_ALLOWED_USERS?.split(",").map(Number) || [];
|
||||
|
||||
if (!BOT_TOKEN) throw new Error("TELEGRAM_BOT_TOKEN missing from .env");
|
||||
if (!ANTHROPIC_KEY) throw new Error("ANTHROPIC_API_KEY missing from .env");
|
||||
if (!SSH_HOST) throw new Error("SSH_HOST missing from .env");
|
||||
|
||||
// ─── Load infra context ───
|
||||
let infraContext = "";
|
||||
try {
|
||||
infraContext = readFileSync(resolve(__dirname, "../.pi/infra.md"), "utf-8");
|
||||
} catch {
|
||||
console.warn("⚠️ Could not load .pi/infra.md");
|
||||
}
|
||||
|
||||
// ─── Init ───
|
||||
const bot = new Bot(BOT_TOKEN);
|
||||
const anthropic = new Anthropic({ apiKey: ANTHROPIC_KEY });
|
||||
|
||||
// Per-user conversation history
|
||||
const conversations = new Map<number, Anthropic.MessageParam[]>();
|
||||
const MAX_HISTORY = 30;
|
||||
|
||||
// ─── Auth middleware ───
|
||||
function isAuthorized(ctx: Context): boolean {
|
||||
if (ALLOWED_USERS.length === 0) return true; // no whitelist = open
|
||||
return ALLOWED_USERS.includes(ctx.from?.id || 0);
|
||||
}
|
||||
|
||||
// ─── SSH exec helper ───
|
||||
async function sshExec(command: string, timeoutSec = 30): Promise<string> {
|
||||
const proc = Bun.spawn(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=accept-new",
|
||||
"-p", SSH_PORT, `${SSH_USER}@${SSH_HOST}`, command],
|
||||
{ stdout: "pipe", stderr: "pipe" }
|
||||
);
|
||||
|
||||
const timer = setTimeout(() => proc.kill(), timeoutSec * 1000);
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
await proc.exited;
|
||||
|
||||
const output = (stdout + stderr).trim();
|
||||
return output.length > 3500 ? output.slice(0, 3500) + "\n...(truncated)" : output;
|
||||
}
|
||||
|
||||
// ─── 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.
|
||||
|
||||
Infrastructure context:
|
||||
${infraContext}
|
||||
|
||||
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.`;
|
||||
|
||||
// ─── 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.",
|
||||
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)" },
|
||||
},
|
||||
required: ["command"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Process message through Claude ───
|
||||
async function chat(userId: number, message: string): Promise<string> {
|
||||
// Get or init conversation history
|
||||
if (!conversations.has(userId)) conversations.set(userId, []);
|
||||
const history = conversations.get(userId)!;
|
||||
|
||||
history.push({ role: "user", content: message });
|
||||
|
||||
// Trim history if too long
|
||||
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,
|
||||
});
|
||||
|
||||
// Tool use loop
|
||||
while (response.stop_reason === "tool_use") {
|
||||
const assistantContent = response.content;
|
||||
history.push({ role: "assistant", content: assistantContent });
|
||||
|
||||
const toolResults: Anthropic.ToolResultBlockParam[] = [];
|
||||
|
||||
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}`);
|
||||
try {
|
||||
const output = await sshExec(input.command, input.timeout || 30);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// Extract text response
|
||||
const text = response.content
|
||||
.filter((b): b is Anthropic.TextBlock => b.type === "text")
|
||||
.map((b) => b.text)
|
||||
.join("\n");
|
||||
|
||||
history.push({ role: "assistant", content: text });
|
||||
|
||||
return text || "(no response)";
|
||||
}
|
||||
|
||||
// ─── Bot handlers ───
|
||||
bot.command("start", (ctx) => {
|
||||
if (!isAuthorized(ctx)) return ctx.reply("⛔ Not authorized.");
|
||||
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" }
|
||||
);
|
||||
});
|
||||
|
||||
bot.command("status", async (ctx) => {
|
||||
if (!isAuthorized(ctx)) return ctx.reply("⛔ Not authorized.");
|
||||
await ctx.reply("🔍 Checking server...");
|
||||
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" });
|
||||
} catch (err: any) {
|
||||
return ctx.reply(`❌ SSH failed: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
bot.command("ssh", async (ctx) => {
|
||||
if (!isAuthorized(ctx)) return ctx.reply("⛔ Not authorized.");
|
||||
const cmd = ctx.match;
|
||||
if (!cmd) return ctx.reply("Usage: `/ssh <command>`", { parse_mode: "Markdown" });
|
||||
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" });
|
||||
} catch (err: any) {
|
||||
return ctx.reply(`❌ ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
bot.command("clear", (ctx) => {
|
||||
if (!isAuthorized(ctx)) return ctx.reply("⛔ Not authorized.");
|
||||
conversations.delete(ctx.from!.id);
|
||||
return ctx.reply("🧹 Conversation cleared.");
|
||||
});
|
||||
|
||||
// Free text → Claude
|
||||
bot.on("message:text", async (ctx) => {
|
||||
if (!isAuthorized(ctx)) return ctx.reply("⛔ Not authorized.");
|
||||
const userId = ctx.from!.id;
|
||||
const msg = ctx.message!.text;
|
||||
|
||||
await ctx.replyWithChatAction("typing");
|
||||
|
||||
try {
|
||||
const reply = await chat(userId, msg);
|
||||
// Split long messages (Telegram 4096 char limit)
|
||||
const chunks = reply.match(/[\s\S]{1,4000}/g) || ["(empty)"];
|
||||
for (const chunk of chunks) {
|
||||
await ctx.reply(chunk, { parse_mode: "Markdown" }).catch(() =>
|
||||
ctx.reply(chunk) // retry without markdown if it fails
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`❌ [${userId}]`, err);
|
||||
await ctx.reply(`❌ Error: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Launch ───
|
||||
console.log("🚀 Bot starting...");
|
||||
bot.start({
|
||||
onStart: (info) => console.log(`✅ @${info.username} is live`),
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "telegram-bot",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.78.0",
|
||||
"grammy": "^1.41.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user