feat(redstone): add OpenRouter primary AI with Gemini fallback

Redstone now hits OpenRouter's `openrouter/free` auto-router first,
falling back to direct Gemini if the OpenRouter call fails (no key,
network error, or `:free` upstream rejected tool use). The auto-
router filters its free-model pool by the request's tool-calling
requirement, so we don't have to pin a specific free model.

- bot/bot.ts:
  - OPENROUTER_API_KEY / _FILE env (mirrors GEMINI_API_KEY_FILE)
  - OPENROUTER_MODEL defaults to "openrouter/free"
  - openRouterTools[] derived from existing geminiFunctionDeclarations
    (OpenAI-style {type:"function", function:{name,description,parameters}})
  - callOpenRouterOnce + runRedstoneTurnViaOpenRouter (parses JSON-string
    tool_calls.arguments, replies with role:"tool" + tool_call_id)
  - Existing Gemini path moved into runRedstoneTurnViaGemini
  - runRedstoneTurn dispatches OpenRouter first, Gemini on null
  - Early-return gate now passes if either key is configured

- docker-compose.yml:
  - new openrouter_token secret -> ./openrouter.token (gitignored)
  - OPENROUTER_API_KEY_FILE + OPENROUTER_MODEL env wired to the bot

- .gitignore: add openrouter.token (plus pre-existing gitea.token entry
  that was sitting uncommitted).

The key file itself is NOT committed (verified via git check-ignore).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 22:42:33 +02:00
parent cab5337e3f
commit 4ebbcef76b
3 changed files with 278 additions and 9 deletions

6
.gitignore vendored
View File

@@ -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

View File

@@ -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 <b>Redstone</b> — the Minecraft server bot.\n\n` +
`Use <code>/up</code> to start the server, <code>/down</code> to stop it, ` +
`<code>/status</code> for the menu, or <code>/help</code> 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 <code>/download</code> 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<pre>${escape(err.slice(0, 500))}</pre>`);
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 <code>/menu</code> → 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-<stamp>` 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: <code>/backup [label]</code> — label must match <code>[A-Za-z0-9._-]{1,40}</code>");
}
await sendText(ctx, `📦 starting backup${rawArg ? ` (label: <code>${escape(rawArg)}</code>)` : ""}`);
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: <url>" line as a clickable link if present.
const urlMatch = combined.match(/Uploaded:\s*(\S+)/);
if (code === 0 && urlMatch) {
await sendText(ctx, `✅ backup uploaded — <a href="${escape(urlMatch[1])}">view release</a>`);
} else if (code === 0) {
await sendText(ctx, `✅ backup done\n<pre>${escape(combined.slice(-500))}</pre>`);
} else {
await sendText(ctx, `❌ backup failed (exit ${code})\n<pre>${escape(combined.slice(-500))}</pre>`);
}
}));
// 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 {
"<b>Minecraft control bot</b>",
"",
"Tap <b>/menu</b> for the panel, or:",
"<code>/start</code> · <code>/stop</code> — start / stop server + tunnel (<code>/up</code>, <code>/down</code> still work)",
"<code>/up</code> · <code>/down</code> — start / stop server + tunnel",
"<code>/server_up</code> · <code>/server_down</code>",
"<code>/pf_up</code> · <code>/pf_down</code>",
"<code>/logs</code> · <code>/pf_logs</code>",
"<code>/download</code> — tar.gz the world to your DM (admin, ≤50 MB)",
"<code>/status</code> — show panel",
"<code>/clear</code> — wipe this chat's audit history (admin only in groups)",
"<code>/whoami</code>",
@@ -908,6 +1027,7 @@ function helpText(role: Role): string {
"<code>/users</code>, <code>/adduser &lt;id|@user&gt; [name]</code>, <code>/rmuser &lt;target&gt;</code>",
"<code>/promote &lt;target&gt;</code>, <code>/demote &lt;target&gt;</code>",
"<code>/linkmc &lt;target&gt; [mc_name]</code> — set/clear someone's MC name",
"<code>/backup [label]</code> — flush, archive, upload as a Gitea release",
"<code>/setsticker &lt;event&gt;</code> (reply to a sticker)",
"<code>/stickers</code>, <code>/teststicker &lt;event&gt;</code>",
"<code>/history [n]</code>",
@@ -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<string, unknown> };
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<string, unknown> } }
@@ -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<TurnResult> {
// ---- 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<ORResponse | null> {
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<TurnResult | null> {
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<string, unknown>;
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<string, unknown> = {};
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<TurnResult> {
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<TurnResult> {
// 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" },

View File

@@ -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