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:
260
bot/bot.ts
260
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 <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 <id|@user> [name]</code>, <code>/rmuser <target></code>",
|
||||
"<code>/promote <target></code>, <code>/demote <target></code>",
|
||||
"<code>/linkmc <target> [mc_name]</code> — set/clear someone's MC name",
|
||||
"<code>/backup [label]</code> — flush, archive, upload as a Gitea release",
|
||||
"<code>/setsticker <event></code> (reply to a sticker)",
|
||||
"<code>/stickers</code>, <code>/teststicker <event></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" },
|
||||
|
||||
Reference in New Issue
Block a user