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-/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: ${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