diff --git a/.pi/infra.md b/.pi/infra.md index 0c05456..21600dc 100644 --- a/.pi/infra.md +++ b/.pi/infra.md @@ -58,3 +58,5 @@ | `GOOGLE_PLACES_API_KEY` | Google Places autocomplete | | `CT_STRAVA_*` | Strava challenge tracker | | `WORDPRESS_URL`, `WORDPRESS_KEY` | WordPress (Cloudways) | +| `TELEGRAM_BOT_TOKEN`, `TELEGRAM_BOT_USERNAME` | Telegram bot (@cr_management_smart_bot) | +| `TELEGRAM_ALLOWED_USERS` | Comma-separated Telegram user IDs (empty = open) | diff --git a/telegram-bot/.gitignore b/telegram-bot/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/telegram-bot/.gitignore @@ -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 diff --git a/telegram-bot/README.md b/telegram-bot/README.md new file mode 100644 index 0000000..41a25e4 --- /dev/null +++ b/telegram-bot/README.md @@ -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 ` — 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) diff --git a/telegram-bot/bun.lock b/telegram-bot/bun.lock new file mode 100644 index 0000000..2f78687 --- /dev/null +++ b/telegram-bot/bun.lock @@ -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=="], + } +} diff --git a/telegram-bot/index.ts b/telegram-bot/index.ts new file mode 100644 index 0000000..197783b --- /dev/null +++ b/telegram-bot/index.ts @@ -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(); +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 { + 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 { + // 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 ` — 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 `", { 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`), +}); diff --git a/telegram-bot/package.json b/telegram-bot/package.json new file mode 100644 index 0000000..6dbdf83 --- /dev/null +++ b/telegram-bot/package.json @@ -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" + } +} diff --git a/telegram-bot/tsconfig.json b/telegram-bot/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/telegram-bot/tsconfig.json @@ -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 + } +}