diff --git a/.gitignore b/.gitignore index 6ff4726..3494410 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,12 @@ bot/data/ # Google Generative Language API key google_key.txt +# Gitea API token (mounted as a docker secret into the bot for release uploads) +gitea.token + +# OpenRouter API key +openrouter.token + # MCP server mcp/node_modules/ mcp/bun.lock diff --git a/bot/bot.ts b/bot/bot.ts index a65962c..fccbca7 100644 --- a/bot/bot.ts +++ b/bot/bot.ts @@ -1,4 +1,4 @@ -import { Bot, Context, GrammyError, HttpError, InlineKeyboard } from "grammy"; +import { Bot, Context, GrammyError, HttpError, InlineKeyboard, InputFile } from "grammy"; import { autoRetry } from "@grammyjs/auto-retry"; import { stream, type StreamFlavor } from "@grammyjs/stream"; @@ -41,6 +41,20 @@ const GEMINI_API_KEY = ( const GEMINI_MODEL = (process.env.GEMINI_MODEL ?? "gemini-2.5-flash-lite").trim(); const GEMINI_ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`; +// ---- OpenRouter (primary) ---- +// `openrouter/free` is OpenRouter's auto-router across free models; it filters +// for models that support whatever features the request needs (tool calling in +// our case). Falls back to direct Gemini below if OpenRouter returns no key, +// errors, or a `:free` upstream rejects tool use. +const OPENROUTER_API_KEY = ( + process.env.OPENROUTER_API_KEY ?? + (process.env.OPENROUTER_API_KEY_FILE && (() => { try { return readFileSync(process.env.OPENROUTER_API_KEY_FILE!, "utf8"); } catch { return ""; } })() || "") +).trim(); +const OPENROUTER_MODEL = (process.env.OPENROUTER_MODEL ?? "openrouter/free").trim(); +const OPENROUTER_ENDPOINT = "https://openrouter.ai/api/v1/chat/completions"; +const OPENROUTER_REFERER = (process.env.OPENROUTER_REFERER ?? "https://thinkstation-web.spot-bot.co.za").trim(); +const OPENROUTER_TITLE = (process.env.OPENROUTER_TITLE ?? "Redstone (minecraft-telegram-bot)").trim(); + // ---- DB ---- mkdirSync(dirname(DB_PATH), { recursive: true }); const db = new Database(DB_PATH); @@ -273,6 +287,11 @@ function unresolvedHelp(): string { // ---- docker compose helpers ---- async function compose(...args: string[]): Promise<{ ok: boolean; out: string }> { + // Auto-inject --remove-orphans on `up` so leftover stale containers from prior + // runs don't keep the new ones off the compose network (DNS misses on service names). + if (args[0] === "up" && !args.includes("--remove-orphans")) { + args = ["up", "--remove-orphans", ...args.slice(1)]; + } const proc = Bun.spawn( [ "docker", "compose", @@ -649,8 +668,19 @@ bot.on("callback_query:data", async (ctx) => { // in-place edits would otherwise be invisible if the existing panel is buried. // Callback button presses still edit in place (they're tied to a visible panel). const dropPanel = (ctx: Context) => Q.delPanel.run(ctx.chat!.id, "main"); -bot.command("start", async (ctx) => { dropPanel(ctx); await actUpAll(ctx); }); -bot.command("stop", async (ctx) => { dropPanel(ctx); await actDownAll(ctx); }); +// /start is Telegram's first-contact command β€” show a welcome + the main panel +// instead of hijacking it to boot the server. Use /up for that. +bot.command("start", async (ctx) => { + const u = (ctx as any).user as User; + const name = escape(u.name ?? "there"); + await sendText( + ctx, + `πŸ‘‹ hi ${name}, I'm Redstone β€” the Minecraft server bot.\n\n` + + `Use /up to start the server, /down to stop it, ` + + `/status for the menu, or /help for everything I can do.`, + ); + await renderMain(ctx); +}); bot.command("menu", async (ctx) => { dropPanel(ctx); await renderMain(ctx); }); bot.command("help", async (ctx) => { await sendText(ctx, helpText((ctx as any).user.role)); @@ -713,6 +743,94 @@ bot.command("pf_down", async (ctx) => { dropPanel(ctx); await actPf(ctx, "do bot.command("logs", async (ctx) => { dropPanel(ctx); await renderLogs(ctx, "srv"); }); bot.command("pf_logs", async (ctx) => { dropPanel(ctx); await renderLogs(ctx, "pf"); }); +// /download β€” tar.gz the current world and send it to the caller as a Telegram +// document. Flushes the world via rcon first when the server is running so the +// snapshot is consistent. Admin-only and DMs-only (big file, possibly sensitive). +bot.command("download", admin(async (ctx) => { + if (ctx.chat?.type !== "private") { + return void await sendText(ctx, "Use /download in a DM with me β€” won't dump a world into a group."); + } + const stamp = new Date().toISOString().replace(/[:T]/g, "-").slice(0, 16); + const outPath = `/tmp/world-${stamp}.tar.gz`; + await sendText(ctx, "πŸ“¦ packaging world… this can take a minute."); + + // Flush the world via rcon if the server is up, then tar inside the container + // so we use docker's view of the volume (matches what backup.sh does). + const serverUp = await serverRunning(); + if (serverUp) { + await Bun.spawn(["docker", "exec", SERVER_CONTAINER, "rcon-cli", "save-off"]).exited; + await Bun.spawn(["docker", "exec", SERVER_CONTAINER, "rcon-cli", "save-all", "flush"]).exited; + } + try { + const tarCmd = serverUp + ? ["docker", "exec", SERVER_CONTAINER, "tar", "-czf", "-", "-C", "/data", "world"] + : ["tar", "-czf", "-", "-C", `${COMPOSE_HOST_DIR}/data`, "world"]; + const proc = Bun.spawn(tarCmd, { stdout: "pipe", stderr: "pipe" }); + await Bun.write(outPath, proc.stdout); + const code = await proc.exited; + if (code !== 0) { + const err = await new Response(proc.stderr).text(); + await sendText(ctx, `❌ tar failed (exit ${code})\n
${escape(err.slice(0, 500))}
`); + return; + } + } finally { + if (serverUp) { + await Bun.spawn(["docker", "exec", SERVER_CONTAINER, "rcon-cli", "save-on"]).exited; + } + } + + // Telegram bot API caps document uploads at 50 MB β€” fail loudly instead of + // a cryptic "Bad Request: file too large" later. + const size = Bun.file(outPath).size; + const MAX_BYTES = 50 * 1024 * 1024; + if (size > MAX_BYTES) { + await sendText( + ctx, + `❌ archive is ${(size / 1024 / 1024).toFixed(1)} MB β€” Telegram bots can only send up to 50 MB. ` + + `Use /menu β†’ Backup (Gitea release) for big worlds.`, + ); + await Bun.spawn(["rm", "-f", outPath]).exited; + return; + } + + try { + await ctx.replyWithDocument(new InputFile(outPath), { + caption: `world snapshot Β· ${(size / 1024 / 1024).toFixed(1)} MB`, + }); + } finally { + await Bun.spawn(["rm", "-f", outPath]).exited; + } +})); + +// /backup β€” run backup.sh: flushes the world via rcon (if up), archives it, +// and uploads to Gitea as a release asset with a `backup-` tag. The +// label arg becomes a filename + tag suffix. Admin-only, output sent inline. +bot.command("backup", admin(async (ctx) => { + const rawArg = (ctx.match as string).trim(); + const labelRe = /^[A-Za-z0-9._-]{1,40}$/; + if (rawArg && !labelRe.test(rawArg)) { + return void await sendText(ctx, "usage: /backup [label] β€” label must match [A-Za-z0-9._-]{1,40}"); + } + await sendText(ctx, `πŸ“¦ starting backup${rawArg ? ` (label: ${escape(rawArg)})` : ""}…`); + const args = rawArg ? ["bash", "/mc/backup.sh", rawArg] : ["bash", "/mc/backup.sh"]; + const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" }); + const [out, err] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const code = await proc.exited; + const combined = (out + err).trim(); + // Surface the "Uploaded: " line as a clickable link if present. + const urlMatch = combined.match(/Uploaded:\s*(\S+)/); + if (code === 0 && urlMatch) { + await sendText(ctx, `βœ… backup uploaded β€” view release`); + } else if (code === 0) { + await sendText(ctx, `βœ… backup done\n
${escape(combined.slice(-500))}
`); + } else { + await sendText(ctx, `❌ backup failed (exit ${code})\n
${escape(combined.slice(-500))}
`); + } +})); + // Wipe this chat's audit-log rows from the messages table β€” shrinks the // context Redstone pulls into its prompt and clears any prior conversation. // DMs: any authorized user can clear their own history. Groups: admin-only. @@ -890,10 +1008,11 @@ function helpText(role: Role): string { "Minecraft control bot", "", "Tap /menu for the panel, or:", - "/start Β· /stop β€” start / stop server + tunnel (/up, /down still work)", + "/up Β· /down β€” start / stop server + tunnel", "/server_up Β· /server_down", "/pf_up Β· /pf_down", "/logs Β· /pf_logs", + "/download β€” tar.gz the world to your DM (admin, ≀50 MB)", "/status β€” show panel", "/clear β€” wipe this chat's audit history (admin only in groups)", "/whoami", @@ -908,6 +1027,7 @@ function helpText(role: Role): string { "/users, /adduser <id|@user> [name], /rmuser <target>", "/promote <target>, /demote <target>", "/linkmc <target> [mc_name] β€” set/clear someone's MC name", + "/backup [label] β€” flush, archive, upload as a Gitea release", "/setsticker <event> (reply to a sticker)", "/stickers, /teststicker <event>", "/history [n]", @@ -954,6 +1074,15 @@ const geminiFunctionDeclarations = allTools .filter((t) => !REDSTONE_HIDDEN_TOOLS.has(t.name)) .map(toolToGeminiFunction); +// OpenAI/OpenRouter tool format wraps the same JSON-Schema parameter blob +// produced for Gemini β€” `cleanForGemini` strips a few keys (`$schema`, +// `additionalProperties`, `default`) which OpenAI also tolerates being absent. +type GeminiFunctionDecl = { name: string; description: string; parameters: Record }; +const openRouterTools = (geminiFunctionDeclarations as GeminiFunctionDecl[]).map((f) => ({ + type: "function" as const, + function: { name: f.name, description: f.description, parameters: f.parameters }, +})); + type GeminiPart = | { text: string } | { functionCall: { name: string; args: Record } } @@ -1122,7 +1251,110 @@ async function callGeminiOnce(contents: GeminiContent[], systemPrompt: string): type TurnResult = { text: string | null; ranTool: boolean }; -async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role): Promise { +// ---- OpenRouter chat-completions types (OpenAI-compatible) ---- +type ORToolCall = { id: string; type: "function"; function: { name: string; arguments: string } }; +type ORMessage = + | { role: "system" | "user"; content: string } + | { role: "assistant"; content: string | null; tool_calls?: ORToolCall[] } + | { role: "tool"; tool_call_id: string; content: string }; +type ORResponse = { + choices?: { message?: { role?: string; content?: string | null; tool_calls?: ORToolCall[] }; finish_reason?: string }[]; + error?: { message?: string }; +}; + +async function callOpenRouterOnce(messages: ORMessage[]): Promise { + if (!OPENROUTER_API_KEY) return null; + try { + const res = await fetch(OPENROUTER_ENDPOINT, { + method: "POST", + headers: { + Authorization: `Bearer ${OPENROUTER_API_KEY}`, + "Content-Type": "application/json", + "HTTP-Referer": OPENROUTER_REFERER, + "X-Title": OPENROUTER_TITLE, + }, + body: JSON.stringify({ + model: OPENROUTER_MODEL, + messages, + tools: openRouterTools, + temperature: 0.6, + max_tokens: 600, + }), + signal: AbortSignal.timeout(45_000), + }); + if (!res.ok) { + console.warn("openrouter http", res.status, (await res.text()).slice(0, 400)); + return null; + } + return (await res.json()) as ORResponse; + } catch (e) { + console.warn("openrouter call failed:", (e as Error).message); + return null; + } +} + +// History is plain text-only (see recentTurns); either provider can render it. +function historyToOpenRouter(history: GeminiContent[]): ORMessage[] { + return history.map((c) => ({ + role: c.role === "user" ? "user" : "assistant", + content: c.parts.map((p) => ("text" in p ? p.text : "")).join(""), + }) as ORMessage); +} + +async function runRedstoneTurnViaOpenRouter( + systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role, +): Promise { + if (!OPENROUTER_API_KEY) return null; + const messages: ORMessage[] = [ + { role: "system", content: systemPrompt }, + ...historyToOpenRouter(history), + { role: "user", content: userText }, + ]; + let ranTool = false; + for (let i = 0; i < 4; i++) { + const data = await callOpenRouterOnce(messages); + if (!data) return null; // hard failure β€” caller falls back to Gemini + const msg = data.choices?.[0]?.message; + if (!msg) return null; + + const toolCalls = msg.tool_calls ?? []; + if (toolCalls.length === 0) { + const text = (msg.content ?? "").trim(); + return { text: text || null, ranTool }; + } + + messages.push({ role: "assistant", content: msg.content ?? null, tool_calls: toolCalls }); + for (const tc of toolCalls) { + const tool = toolsByName.get(tc.function.name); + let response: Record; + if (!tool) { + response = { error: `unknown tool '${tc.function.name}'` }; + } else if (tool.requiresAdmin && callerRole !== "admin") { + response = { error: `tool '${tc.function.name}' requires admin role; the calling user is '${callerRole}'. Inform the user, do not retry.` }; + } else { + let args: Record = {}; + try { args = tc.function.arguments ? JSON.parse(tc.function.arguments) : {}; } catch { + response = { error: `invalid JSON in tool arguments for '${tc.function.name}'` }; + messages.push({ role: "tool", tool_call_id: tc.id, content: JSON.stringify(response) }); + continue; + } + try { + const result = await tool.handler(args, toolCtx); + response = { result }; + ranTool = true; + } catch (e) { + response = { error: (e as Error).message }; + } + } + messages.push({ role: "tool", tool_call_id: tc.id, content: JSON.stringify(response) }); + } + } + return { text: null, ranTool }; +} + +async function runRedstoneTurnViaGemini( + systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role, +): Promise { const contents: GeminiContent[] = [...history, { role: "user", parts: [{ text: userText }] }]; let ranTool = false; for (let i = 0; i < 4; i++) { @@ -1167,11 +1399,20 @@ async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], u return { text: null, ranTool }; } +async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role): Promise { + // OpenRouter `openrouter/free` is the primary path β€” picks the best free + // tool-calling model automatically. Falls through to Gemini on null (no key, + // network error, upstream rejected tool use). + const orResult = await runRedstoneTurnViaOpenRouter(systemPrompt, history, userText, callerRole); + if (orResult !== null) return orResult; + return runRedstoneTurnViaGemini(systemPrompt, history, userText, callerRole); +} + bot.on("message:text", async (ctx) => { const me = bot.botInfo; if (!me) return; if (!botAddressed(ctx, me.id, me.username)) return; - if (!GEMINI_API_KEY) return; + if (!OPENROUTER_API_KEY && !GEMINI_API_KEY) return; // Hard authorization gate. Re-checks the DB at handler time rather than // trusting the upstream auth middleware, so any future refactor that @@ -1294,8 +1535,9 @@ process.once("SIGTERM", () => shutdown("SIGTERM")); // ---- Telegram command registration ---- const USER_COMMANDS = [ - { command: "start", description: "Start server and tunnel" }, - { command: "stop", description: "Stop server and tunnel" }, + { command: "start", description: "Welcome + show the control panel" }, + { command: "up", description: "Start server and tunnel" }, + { command: "down", description: "Stop server and tunnel" }, { command: "menu", description: "Show the control panel" }, { command: "status", description: "Server + tunnel status" }, { command: "logs", description: "Last 30 lines of server log" }, @@ -1316,6 +1558,8 @@ const ADMIN_EXTRA = [ { command: "promote", description: "Make user admin" }, { command: "demote", description: "Demote admin to user" }, { command: "linkmc", description: "Set/clear someone's MC name" }, + { command: "download", description: "Download a tar.gz of the world (DM)" }, + { command: "backup", description: "Run world backup β†’ Gitea release" }, { command: "setsticker", description: "Reply to a sticker to bind it" }, { command: "stickers", description: "List bound stickers" }, { command: "teststicker", description: "Preview a sticker by event" }, diff --git a/docker-compose.yml b/docker-compose.yml index 63198d9..0242654 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,12 +50,25 @@ services: environment: BOT_TOKEN_FILE: /run/secrets/bot_token GEMINI_API_KEY_FILE: /run/secrets/google_key - GEMINI_MODEL: "${GEMINI_MODEL:-gemma-4-26b-a4b-it}" + GEMINI_MODEL: "${GEMINI_MODEL:-gemini-2.5-flash-lite}" + # OpenRouter primary; Gemini stays as fallback. `openrouter/free` auto- + # selects a free model that supports tool calling for this request. + OPENROUTER_API_KEY_FILE: /run/secrets/openrouter_token + OPENROUTER_MODEL: "${OPENROUTER_MODEL:-openrouter/free}" MC_DIR: /mc DB_PATH: /data/users.db ADMIN_BOOTSTRAP: "${ADMIN_BOOTSTRAP:-1311866578:Paul}" COMPOSE_HOST_DIR: "${COMPOSE_HOST_DIR:-/home/paul/containers/minecraft}" TZ: Africa/Johannesburg + # Gitea release upload from inside the container. The token is mounted + # as a docker secret (see `secrets:` below + ./gitea.token, gitignored); + # backup.sh reads it via $GITEA_TOKEN_FILE. GITEA_API_BASE / GITEA_REPO + # let backup.sh skip its `git config remote.origin.url` parse β€” needed + # because the host's origin URL points at localhost:3000, which the bot + # container can't reach. + GITEA_TOKEN_FILE: /run/secrets/gitea_token + GITEA_API_BASE: "${GITEA_API_BASE:-https://thinkstation-web.spot-bot.co.za/api/v1}" + GITEA_REPO: "${GITEA_REPO:-paul/Minecraft-Server}" volumes: - /var/run/docker.sock:/var/run/docker.sock - .:/mc @@ -63,9 +76,15 @@ services: secrets: - bot_token - google_key + - gitea_token + - openrouter_token secrets: bot_token: file: ./bot.token google_key: file: ./google_key.txt + gitea_token: + file: ./gitea.token + openrouter_token: + file: ./openrouter.token