Compare commits
14 Commits
backup-202
...
backup-202
| Author | SHA1 | Date | |
|---|---|---|---|
| 978211dc65 | |||
| 3647174c30 | |||
| 633a4443c2 | |||
| a9a8bbe3dd | |||
| aae9ddd429 | |||
| 7217e3553b | |||
| f1d4fb596a | |||
| 8ae89610f3 | |||
| 811bf582c6 | |||
| f05db39903 | |||
| 404fee3405 | |||
| b99c36c13c | |||
| bc87681085 | |||
| 4ebbcef76b |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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
|
||||
|
||||
583
bot/bot.ts
583
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);
|
||||
@@ -89,6 +103,11 @@ db.exec(`
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sticker_library_last_seen
|
||||
ON sticker_library(last_seen DESC);
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
|
||||
// Idempotent migrations.
|
||||
@@ -273,6 +292,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",
|
||||
@@ -498,18 +522,24 @@ function fmtTs(): string {
|
||||
|
||||
async function renderMain(ctx: Context) {
|
||||
const [mc, pf] = await Promise.all([serverRunning(), pfRunning()]);
|
||||
const allUp = mc && pf;
|
||||
const allDown = !mc && !pf;
|
||||
const headline = allUp ? "🟢 <b>Online</b>" : allDown ? "🔴 <b>Offline</b>" : "🟡 <b>Partial</b>";
|
||||
const dot = (ok: boolean) => (ok ? "🟢" : "🔴");
|
||||
const text =
|
||||
`<b>🎮 Minecraft Control</b>\n` +
|
||||
`🎮 <b>Minecraft</b>\n` +
|
||||
`${headline}\n` +
|
||||
`\n` +
|
||||
`server : ${mc ? "✅ up" : "⛔ down"}\n` +
|
||||
`tunnel : ${pf ? "✅ up" : "⛔ down"}\n` +
|
||||
`${dot(mc)} Server\n` +
|
||||
`${dot(pf)} Tunnel\n` +
|
||||
`\n` +
|
||||
`<i>updated ${fmtTs()}</i>`;
|
||||
`<i>⏱ ${fmtTs()}</i>`;
|
||||
// Single control button: power toggle. Drives both server + tunnel at once
|
||||
// via actUpAll / actDownAll. Granular per-service control is still reachable
|
||||
// via the /server_up, /server_down, /pf_up, /pf_down slash commands.
|
||||
const kb = new InlineKeyboard()
|
||||
.text(mc && pf ? "⏹ Down All" : "▶ Up All", mc && pf ? "act:downall" : "act:upall").row()
|
||||
.text(mc ? "🟢 Server ▾" : "🟢 Server ▾", "nav:server").text(pf ? "🟡 Tunnel ▾" : "🟡 Tunnel ▾", "nav:tunnel").row()
|
||||
.text("📜 Logs", "nav:logs").text("👥 Users", "nav:users").row()
|
||||
.text("🔄 Refresh", "nav:main");
|
||||
.text(allUp ? "🛑 Stop" : "▶️ Start", allUp ? "act:downall" : "act:upall").row()
|
||||
.text("📜 Logs", "nav:logs").text("👥 Users", "nav:users").text("🔄", "nav:main");
|
||||
return editPanel(ctx, "main", text, kb);
|
||||
}
|
||||
|
||||
@@ -567,13 +597,174 @@ async function renderConfirm(ctx: Context, action: string, prompt: string) {
|
||||
return editPanel(ctx, "main", text, kb);
|
||||
}
|
||||
|
||||
// ---- OpenRouter model picker ----
|
||||
type ORModelListItem = {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
pricing?: { prompt?: string; completion?: string };
|
||||
supported_parameters?: string[];
|
||||
context_length?: number;
|
||||
};
|
||||
|
||||
function isFreeModel(m: ORModelListItem): boolean {
|
||||
if (m.id.endsWith(":free") || m.id === "openrouter/free") return true;
|
||||
const p = Number.parseFloat(m.pricing?.prompt ?? "");
|
||||
const c = Number.parseFloat(m.pricing?.completion ?? "");
|
||||
return Number.isFinite(p) && Number.isFinite(c) && p === 0 && c === 0;
|
||||
}
|
||||
|
||||
async function fetchOpenRouterModels(): Promise<ORModelListItem[]> {
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (OPENROUTER_API_KEY) headers.Authorization = `Bearer ${OPENROUTER_API_KEY}`;
|
||||
const res = await fetch("https://openrouter.ai/api/v1/models", {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`openrouter /models -> ${res.status}`);
|
||||
const data = (await res.json()) as { data?: ORModelListItem[] };
|
||||
return data.data ?? [];
|
||||
}
|
||||
|
||||
// OpenRouter key/usage info — GET /api/v1/key (equivalent to the SDK's
|
||||
// openRouter.apiKeys.getCurrent()). Surfaces credit usage + tier.
|
||||
type ORKeyInfo = {
|
||||
label?: string;
|
||||
is_free_tier?: boolean;
|
||||
limit?: number | null;
|
||||
limit_remaining?: number | null;
|
||||
usage?: number;
|
||||
usage_daily?: number;
|
||||
usage_weekly?: number;
|
||||
usage_monthly?: number;
|
||||
};
|
||||
|
||||
async function fetchOpenRouterKeyInfo(): Promise<ORKeyInfo> {
|
||||
if (!OPENROUTER_API_KEY) throw new Error("OPENROUTER_API_KEY not configured");
|
||||
const res = await fetch("https://openrouter.ai/api/v1/key", {
|
||||
headers: { Authorization: `Bearer ${OPENROUTER_API_KEY}`, Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`openrouter /key -> ${res.status}`);
|
||||
const data = (await res.json()) as { data?: ORKeyInfo };
|
||||
return data.data ?? {};
|
||||
}
|
||||
|
||||
async function renderAiUsage(ctx: Context) {
|
||||
if (!OPENROUTER_API_KEY) {
|
||||
await sendText(ctx, "OpenRouter is not configured — set OPENROUTER_API_KEY to see usage.");
|
||||
return;
|
||||
}
|
||||
let info: ORKeyInfo;
|
||||
try {
|
||||
info = await fetchOpenRouterKeyInfo();
|
||||
} catch (e) {
|
||||
await sendText(ctx, `Couldn't load OpenRouter usage: ${escape((e as Error).message)}`);
|
||||
return;
|
||||
}
|
||||
const usd = (n: number | null | undefined) =>
|
||||
n == null ? "—" : `$${n.toFixed(n < 1 ? 4 : 2)}`;
|
||||
const tier = info.is_free_tier ? "🟢 free tier" : "💰 paid";
|
||||
const limitLine = info.limit == null
|
||||
? "Credit limit: <i>none</i>"
|
||||
: `Credit limit: ${usd(info.limit)} (remaining ${usd(info.limit_remaining)})`;
|
||||
const text =
|
||||
`<b>🤖 OpenRouter usage</b>\n\n` +
|
||||
`Active model: <code>${escape(currentOpenRouterModel())}</code>\n` +
|
||||
`Tier: ${tier}\n` +
|
||||
`${limitLine}\n\n` +
|
||||
`<b>Credits spent</b>\n` +
|
||||
`• today: ${usd(info.usage_daily)}\n` +
|
||||
`• this week: ${usd(info.usage_weekly)}\n` +
|
||||
`• this month: ${usd(info.usage_monthly)}\n` +
|
||||
`• all time: ${usd(info.usage)}\n\n` +
|
||||
`<i>Free-model requests don't consume credits — they're capped separately at 50/day, resetting at UTC midnight. Add $10 of credit to lift that to 1000/day.</i>`;
|
||||
await sendText(ctx, text);
|
||||
}
|
||||
|
||||
function modelButtonLabel(m: ORModelListItem, isCurrent: boolean): string {
|
||||
const free = isFreeModel(m);
|
||||
const thinking = m.supported_parameters?.includes("reasoning") ?? false;
|
||||
const flag = isCurrent ? "✅ " : "";
|
||||
// 🟢 free / 💰 paid · 🛠 tools (guaranteed by filter) · 🧠 thinking (or
|
||||
// a half-width spacer when absent so columns stay aligned in the keyboard).
|
||||
const badges = `${free ? "🟢" : "💰"}🛠${thinking ? "🧠" : "·"}`;
|
||||
const name = (m.name || m.id).replace(/\s*\(free\)\s*$/i, "");
|
||||
return `${flag}${badges} ${name}`.slice(0, 64);
|
||||
}
|
||||
|
||||
async function renderModelPicker(ctx: Context) {
|
||||
if (!OPENROUTER_API_KEY) {
|
||||
await sendText(ctx, "OpenRouter is not configured — set OPENROUTER_API_KEY to enable model switching.");
|
||||
return;
|
||||
}
|
||||
const current = currentOpenRouterModel();
|
||||
let models: ORModelListItem[];
|
||||
try {
|
||||
models = await fetchOpenRouterModels();
|
||||
} catch (e) {
|
||||
await sendText(ctx, `Couldn't load model list: ${(e as Error).message}`);
|
||||
return;
|
||||
}
|
||||
// Must support tool calling — Redstone won't work otherwise.
|
||||
const usable = models.filter((m) => m.supported_parameters?.includes("tools"));
|
||||
// Router models (auto-pickers) pinned to the top, then alpha by display name.
|
||||
const ROUTER_RE = /^openrouter\/(free|auto)\b/;
|
||||
const routers = usable.filter((m) => ROUTER_RE.test(m.id));
|
||||
const rest = usable.filter((m) => !ROUTER_RE.test(m.id))
|
||||
.sort((a, b) => {
|
||||
const af = isFreeModel(a), bf = isFreeModel(b);
|
||||
if (af !== bf) return af ? -1 : 1;
|
||||
return (a.name || a.id).localeCompare(b.name || b.id);
|
||||
});
|
||||
const list = [...routers, ...rest].slice(0, 20); // Telegram keyboard practicality
|
||||
if (list.length === 0) {
|
||||
await sendText(ctx, "No tool-capable models returned by OpenRouter.");
|
||||
return;
|
||||
}
|
||||
const kb = new InlineKeyboard();
|
||||
for (const m of list) {
|
||||
kb.text(modelButtonLabel(m, m.id === current), `model:${m.id}`).row();
|
||||
}
|
||||
const header =
|
||||
`<b>🧠 LLM model picker</b>\n\n` +
|
||||
`Active: <code>${escape(current)}</code>\n\n` +
|
||||
`🟢 free · 💰 paid · 🛠 tools · 🧠 thinking`;
|
||||
await sendText(ctx, header, kb);
|
||||
}
|
||||
|
||||
async function selectModel(ctx: Context, modelId: string) {
|
||||
const c = ctx as Context & { user?: User };
|
||||
if (c.user?.role !== "admin") {
|
||||
await ctx.answerCallbackQuery({ text: "admin only", show_alert: true });
|
||||
return;
|
||||
}
|
||||
kvSet("llm_model", modelId);
|
||||
await ctx.answerCallbackQuery({ text: `model set: ${modelId}` });
|
||||
// Dismiss the keyboard and replace the picker body with a confirmation.
|
||||
// editMessageText with no reply_markup clears the inline keyboard.
|
||||
try {
|
||||
await ctx.editMessageText(
|
||||
`<b>🧠 LLM model</b>\n\nActive: <code>${escape(modelId)}</code>\n\n<i>Switched. The new model takes effect on the next message.</i>`,
|
||||
{ parse_mode: "HTML" },
|
||||
);
|
||||
} catch {
|
||||
/* edit can fail if the original message is too old; the toast already confirmed */
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Action helpers ----
|
||||
async function answerToast(ctx: Context, text: string) {
|
||||
if (ctx.callbackQuery) await ctx.answerCallbackQuery({ text }).catch(() => {});
|
||||
}
|
||||
|
||||
async function actUpAll(ctx: Context) {
|
||||
const r = await compose("up", "-d");
|
||||
// Only the server + tunnel — never the bot itself. A bare `compose up -d`
|
||||
// would re-evaluate every service in the project, recreate the bot
|
||||
// container, SIGTERM the running process mid-call, and leave a renamed
|
||||
// replacement stuck in "Created" state because the name collision prevented
|
||||
// it from starting. Matches actDownAll, which already scopes to these two.
|
||||
const r = await compose("up", "-d", SERVER_SVC, PF_SVC);
|
||||
await answerToast(ctx, r.ok ? "starting…" : "failed");
|
||||
await maybeSendSticker(ctx, r.ok ? "up" : "error");
|
||||
await renderMain(ctx);
|
||||
@@ -629,6 +820,8 @@ bot.on("callback_query:data", async (ctx) => {
|
||||
if (data === "act:logs:srv") return void await renderLogs(ctx, "srv");
|
||||
if (data === "act:logs:pf") return void await renderLogs(ctx, "pf");
|
||||
|
||||
if (data.startsWith("model:")) return void await selectModel(ctx, data.slice("model:".length));
|
||||
|
||||
if (data === "info:adduser") {
|
||||
await ctx.answerCallbackQuery({ text: "use /adduser @username [name] (or numeric id, or @me)", show_alert: true });
|
||||
return;
|
||||
@@ -649,8 +842,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 +917,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.
|
||||
@@ -730,6 +1022,10 @@ bot.command("clear", async (ctx) => {
|
||||
).get(chatId)?.n ?? 0;
|
||||
db.query<unknown, [number]>("DELETE FROM messages WHERE chat_id = ?").run(chatId);
|
||||
await sendText(ctx, `🧹 cleared <b>${before}</b> message${before === 1 ? "" : "s"} from this chat.`);
|
||||
// sendText auto-logs the confirmation back into messages → the next /clear
|
||||
// would otherwise count it as a leftover. Wipe again so the chat is truly
|
||||
// empty afterwards and subsequent /clear keeps returning 0.
|
||||
db.query<unknown, [number]>("DELETE FROM messages WHERE chat_id = ?").run(chatId);
|
||||
});
|
||||
|
||||
// User management
|
||||
@@ -859,6 +1155,14 @@ bot.command("teststicker", admin(async (ctx) => {
|
||||
await maybeSendSticker(ctx, event);
|
||||
}));
|
||||
|
||||
// LLM model picker — admin can switch the OpenRouter model the bot uses
|
||||
// for Redstone replies. Persists in settings(llm_model); active selection
|
||||
// is resolved at every callOpenRouterOnce so changes apply immediately.
|
||||
bot.command("model", admin(async (ctx) => { await renderModelPicker(ctx); }));
|
||||
|
||||
// OpenRouter usage — admin views credit spend + tier for the configured key.
|
||||
bot.command("ai_use", admin(async (ctx) => { await renderAiUsage(ctx); }));
|
||||
|
||||
// History — last N messages in this chat
|
||||
bot.command("history", admin(async (ctx) => {
|
||||
const n = Math.min(50, Math.max(5, Number.parseInt((ctx.match as string).trim(), 10) || 20));
|
||||
@@ -890,10 +1194,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 +1213,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>",
|
||||
@@ -928,6 +1234,7 @@ import {
|
||||
} from "/mc/mcp/lib/types.ts";
|
||||
import { telegramTools } from "/mc/mcp/lib/telegram-tools.ts";
|
||||
import { minecraftTools } from "/mc/mcp/lib/minecraft-tools.ts";
|
||||
import { minecraftWikiTools } from "/mc/mcp/lib/minecraft-wiki-tools.ts";
|
||||
|
||||
const tgClient = createTgClient(TOKEN);
|
||||
const mcRuntime = createMcRuntime({
|
||||
@@ -941,7 +1248,7 @@ const mcRuntime = createMcRuntime({
|
||||
backupSh: `${MC_DIR}/backup.sh`,
|
||||
});
|
||||
const toolCtx: ToolCtx = { tg: tgClient, db, mc: mcRuntime };
|
||||
const allTools = [...telegramTools, ...minecraftTools];
|
||||
const allTools = [...telegramTools, ...minecraftTools, ...minecraftWikiTools];
|
||||
const toolsByName = new Map(allTools.map((t) => [t.name, t]));
|
||||
// Hidden from Redstone's view: emoji→sticker is handled by post-processing
|
||||
// the reply text, so the model never has to manage file_ids itself.
|
||||
@@ -954,12 +1261,35 @@ 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> } }
|
||||
| { functionResponse: { name: string; response: Record<string, unknown> } };
|
||||
type GeminiContent = { role: "user" | "model"; parts: GeminiPart[] };
|
||||
|
||||
// Generic kv-style settings store. The /model command writes the active
|
||||
// OpenRouter model id here, and callOpenRouterOnce reads it at call time
|
||||
// so changes take effect on the very next message — no restart needed.
|
||||
const Q_kvGet = db.query<{ value: string }, [string]>("SELECT value FROM settings WHERE key = ?");
|
||||
const Q_kvSet = db.query<unknown, [string, string]>(
|
||||
"INSERT INTO settings (key, value) VALUES (?, ?) " +
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=datetime('now')",
|
||||
);
|
||||
function kvGet(key: string): string | null { return Q_kvGet.get(key)?.value ?? null; }
|
||||
function kvSet(key: string, value: string): void { Q_kvSet.run(key, value); }
|
||||
function currentOpenRouterModel(): string {
|
||||
return kvGet("llm_model") || OPENROUTER_MODEL;
|
||||
}
|
||||
|
||||
// Pull the last n text messages from this chat for continuity.
|
||||
const Q_chatHistory = db.query<
|
||||
{ direction: "in" | "out"; body: string | null },
|
||||
@@ -997,6 +1327,11 @@ function startTypingLoop(ctx: Context, chatId: number): () => void {
|
||||
return () => { stopped = true; };
|
||||
}
|
||||
|
||||
// Persona names Redstone answers to in groups when @-mention is missing.
|
||||
// Word-boundary match; case-insensitive. Lets "hey steve, …" land without
|
||||
// requiring users to remember the bot's @handle.
|
||||
const PERSONA_NAME_RE = /\b(redstone|steve)\b/i;
|
||||
|
||||
function botAddressed(ctx: Context, botId: number, botUsername: string): boolean {
|
||||
const m = ctx.message;
|
||||
if (!m || !m.text) return false;
|
||||
@@ -1009,6 +1344,7 @@ function botAddressed(ctx: Context, botId: number, botUsername: string): boolean
|
||||
if (slice.toLowerCase() === `@${botUsername.toLowerCase()}`) return true;
|
||||
} else if (e.type === "text_mention" && e.user?.id === botId) return true;
|
||||
}
|
||||
if (PERSONA_NAME_RE.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1062,6 +1398,12 @@ function redstonePersona(user: User, chatType: string, chatId: number, srv: stri
|
||||
"- Telegram chat: send messages/polls/reactions/dice/stickers/photos, edit/pin/delete your own messages, query chat info (tg_*).",
|
||||
"- Minecraft server: check status (server_status), start/stop/restart services (server_up/down/restart), tail logs (server_logs), look up players in the bot's database (players_list/player_get).",
|
||||
"- Admin-only tools (the caller's role is shown in the context block): run RCON commands (rcon), trigger a world backup (backup), and modify player records (player_upsert/set_*/remove, seen_list). If a non-admin asks for one, politely refuse and don't call it.",
|
||||
"- Minecraft wiki: look things up on minecraft.wiki and surface images into the chat.",
|
||||
"- For 'recipe for X' / 'how do I craft X' / 'send me the recipe image': call wiki_recipe_image(title=…, chat=<chatId>) — ONE call that composites a real 3×3 grid PNG from the wiki's slot sprites and uploads it straight to the chat. Returns {sent:true,message_id} when it's already in the chat — you do NOT need a separate tg_send_photo turn afterwards. Only add a short text reply if you want to caption the recipe with extra context.",
|
||||
"- If wiki_recipe_image fails (e.g. 'no 3×3 crafting recipe' — the item is smelted, brewed, or only obtained from loot), use wiki_recipe to fetch the ingredient text, then tg_send_photo with the page's thumbnail_url as a fallback so SOMETHING visual lands.",
|
||||
"- For 'what is X?' / general lookup: wiki_page for the intro extract + thumbnail. Then a short text reply, optionally + tg_send_photo of the thumbnail.",
|
||||
"- For specific non-thumbnail images (block variants, GUI screenshots, charts): wiki_page_images with an optional filename filter.",
|
||||
"- IMAGE DELIVERY IS MANDATORY when the user asks for a picture / image / recipe / what something looks like. End the turn with a tool call that actually puts a photo into the chat (wiki_recipe_image OR tg_send_photo). Never describe an image in words and stop.",
|
||||
"- When you use a tool, you usually don't need a separate text reply unless you're adding context — the tool's effect IS the response. After a server_status / players_list lookup, summarize the answer in one sentence.",
|
||||
"",
|
||||
"Stickers — drop emojis inline and the bot does the rest:",
|
||||
@@ -1122,10 +1464,167 @@ 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: currentOpenRouterModel(),
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Wiki calls can be slow when minecraft.wiki rate-limits or just lags. For
|
||||
// tools that match WIKI_TOOL_RE, show a visible "I'm working on it" message
|
||||
// in the chat with a word rotation — but only if the tool actually takes
|
||||
// longer than SLOW_TOOL_THRESHOLD_MS. Fast calls stay invisible.
|
||||
const WIKI_TOOL_RE = /^wiki_/;
|
||||
const SLOW_TOOL_THRESHOLD_MS = 1500;
|
||||
const SLOW_TOOL_ROTATE_MS = 900;
|
||||
const SLOW_TOOL_PHRASES = [
|
||||
"🔍 looking it up…",
|
||||
"📖 reading the wiki…",
|
||||
"🔎 searching…",
|
||||
"🧭 cross-referencing…",
|
||||
];
|
||||
|
||||
async function withSlowToolIndicator<T>(chatId: number, toolName: string, fn: () => Promise<T>): Promise<T> {
|
||||
if (!WIKI_TOOL_RE.test(toolName)) return fn();
|
||||
let placeholderId: number | null = null;
|
||||
let rotateTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let done = false;
|
||||
let phraseIdx = 0;
|
||||
const showTimer = setTimeout(async () => {
|
||||
try {
|
||||
const m = await bot.api.sendMessage(chatId, SLOW_TOOL_PHRASES[0]);
|
||||
// fn() may have resolved while sendMessage was in flight — if so the
|
||||
// finally block already ran and saw no placeholder/timer to clean up.
|
||||
// Delete the just-sent message and bail instead of installing an
|
||||
// orphaned rotation loop that nothing will ever clear.
|
||||
if (done) {
|
||||
try { await bot.api.deleteMessage(chatId, m.message_id); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
placeholderId = m.message_id;
|
||||
rotateTimer = setInterval(async () => {
|
||||
phraseIdx = (phraseIdx + 1) % SLOW_TOOL_PHRASES.length;
|
||||
try { await bot.api.editMessageText(chatId, placeholderId!, SLOW_TOOL_PHRASES[phraseIdx]); }
|
||||
catch { /* edit failures (rate-limit / deleted) are fine */ }
|
||||
}, SLOW_TOOL_ROTATE_MS);
|
||||
} catch { /* send failure → just no indicator */ }
|
||||
}, SLOW_TOOL_THRESHOLD_MS);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
done = true;
|
||||
clearTimeout(showTimer);
|
||||
if (rotateTimer) clearInterval(rotateTimer);
|
||||
if (placeholderId != null) {
|
||||
try { await bot.api.deleteMessage(chatId, placeholderId); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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, chatId: number,
|
||||
): 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 < 6; 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 {
|
||||
console.info("[redstone:or] tool ->", tc.function.name, JSON.stringify(args).slice(0, 200));
|
||||
const result = await withSlowToolIndicator(chatId, tc.function.name, () => tool.handler(args, toolCtx));
|
||||
response = { result };
|
||||
ranTool = true;
|
||||
console.info("[redstone:or] tool <-", tc.function.name, "ok");
|
||||
} catch (e) {
|
||||
response = { error: (e as Error).message };
|
||||
console.warn("[redstone:or] tool <-", tc.function.name, "err:", (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, chatId: number,
|
||||
): Promise<TurnResult> {
|
||||
const contents: GeminiContent[] = [...history, { role: "user", parts: [{ text: userText }] }];
|
||||
let ranTool = false;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const data = await callGeminiOnce(contents, systemPrompt);
|
||||
if (!data) return { text: null, ranTool };
|
||||
const parts = data.candidates?.[0]?.content?.parts ?? [];
|
||||
@@ -1153,11 +1652,14 @@ async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], u
|
||||
response = { error: `tool '${fc.functionCall.name}' requires admin role; the calling user is '${callerRole}'. Inform the user, do not retry.` };
|
||||
} else {
|
||||
try {
|
||||
const result = await tool.handler(fc.functionCall.args, toolCtx);
|
||||
console.info("[redstone:gem] tool ->", fc.functionCall.name, JSON.stringify(fc.functionCall.args).slice(0, 200));
|
||||
const result = await withSlowToolIndicator(chatId, fc.functionCall.name, () => tool.handler(fc.functionCall.args, toolCtx));
|
||||
response = { result };
|
||||
ranTool = true;
|
||||
console.info("[redstone:gem] tool <-", fc.functionCall.name, "ok");
|
||||
} catch (e) {
|
||||
response = { error: (e as Error).message };
|
||||
console.warn("[redstone:gem] tool <-", fc.functionCall.name, "err:", (e as Error).message);
|
||||
}
|
||||
}
|
||||
responseParts.push({ functionResponse: { name: fc.functionCall.name, response } });
|
||||
@@ -1167,11 +1669,20 @@ async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], u
|
||||
return { text: null, ranTool };
|
||||
}
|
||||
|
||||
async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role, chatId: number): 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, chatId);
|
||||
if (orResult !== null) return orResult;
|
||||
return runRedstoneTurnViaGemini(systemPrompt, history, userText, callerRole, chatId);
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1203,7 +1714,7 @@ bot.on("message:text", async (ctx) => {
|
||||
const sys = redstonePersona(user, chatType, chatId, srv);
|
||||
const latest = Q_latestMsgId.get(chatId)?.id ?? 0;
|
||||
const history = recentTurns(chatId, latest, 6);
|
||||
const result = await runRedstoneTurn(sys, history, text, user.role);
|
||||
const result = await runRedstoneTurn(sys, history, text, user.role, chatId);
|
||||
reply = result.text;
|
||||
ranTool = result.ranTool;
|
||||
} finally {
|
||||
@@ -1211,7 +1722,21 @@ bot.on("message:text", async (ctx) => {
|
||||
}
|
||||
|
||||
if (!reply) {
|
||||
if (!ranTool) console.warn("redstone: no reply and no tool ran for", { chatId, text: text.slice(0, 80) });
|
||||
if (!ranTool) {
|
||||
// Both providers returned null AND nothing useful happened during the
|
||||
// turn — surface that to the user instead of silently dropping the
|
||||
// message. Usually means OpenRouter hit its free-tier rate limit and
|
||||
// Gemini is also down/throttled; tell them what to do.
|
||||
console.warn("redstone: no reply and no tool ran for", { chatId, text: text.slice(0, 80) });
|
||||
try {
|
||||
await ctx.reply(
|
||||
"🤖 AI is unreachable right now — both OpenRouter and Gemini just declined. "
|
||||
+ "Usually the free-tier daily cap resetting around UTC midnight. Try again in a few minutes, "
|
||||
+ "or use <code>/model</code> to pick a different model.",
|
||||
{ parse_mode: "HTML", reply_parameters: { message_id: ctx.message.message_id } },
|
||||
);
|
||||
} catch { /* if even the error reply fails, nothing more we can do */ }
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1219,10 +1744,19 @@ bot.on("message:text", async (ctx) => {
|
||||
// Stream the reply word-by-word so the message animates into the chat like
|
||||
// an LLM typing. @grammyjs/stream uses sendMessageDraft (Bot API 9.5) to push
|
||||
// each delta as a native, animated draft and finalizes with sendMessage.
|
||||
// sendMessageDraft is only supported in private chats — group / supergroup
|
||||
// / channel peers return TEXTDRAFT_PEER_INVALID. Use plain reply there.
|
||||
let finalMessage: { message_id: number } | undefined;
|
||||
if (chatType === "private") {
|
||||
const messages = await ctx.replyWithStream(streamWords(out), {}, {
|
||||
reply_parameters: { message_id: ctx.message.message_id },
|
||||
});
|
||||
const finalMessage = messages[messages.length - 1];
|
||||
finalMessage = messages[messages.length - 1];
|
||||
} else {
|
||||
finalMessage = await ctx.reply(out, {
|
||||
reply_parameters: { message_id: ctx.message.message_id },
|
||||
});
|
||||
}
|
||||
if (finalMessage) {
|
||||
Q.insMsg.run(chatId, finalMessage.message_id, null, "out", "text", out, ctx.message.message_id);
|
||||
}
|
||||
@@ -1294,8 +1828,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 +1851,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" },
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"dependencies": {
|
||||
"@grammyjs/auto-retry": "^2.0.2",
|
||||
"@grammyjs/stream": "^1.0.1",
|
||||
"grammy": "^1.30.0"
|
||||
"grammy": "^1.30.0",
|
||||
"sharp": "^0.33.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
|
||||
@@ -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
|
||||
|
||||
392
mcp/lib/minecraft-wiki-tools.ts
Normal file
392
mcp/lib/minecraft-wiki-tools.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
// Minecraft Wiki tool catalog. Hits https://minecraft.wiki (MediaWiki API).
|
||||
// Composes well with telegram-tools.ts → after wiki_page_images returns a URL
|
||||
// the model can pass it to tg_send_photo to surface a recipe / item image
|
||||
// directly into the chat.
|
||||
import { z } from "zod";
|
||||
import sharp from "sharp";
|
||||
import { tool, type Tool } from "./types.ts";
|
||||
|
||||
const WIKI_BASE = "https://minecraft.wiki";
|
||||
const WIKI_API = `${WIKI_BASE}/api.php`;
|
||||
const UA = "Redstone-bot/1.0 (Minecraft container; contact via telegram bot)";
|
||||
const TIMEOUT_MS = 15_000;
|
||||
|
||||
async function wikiGet<T>(params: Record<string, string | number | undefined>): Promise<T> {
|
||||
const url = new URL(WIKI_API);
|
||||
url.searchParams.set("format", "json");
|
||||
url.searchParams.set("formatversion", "2");
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined) url.searchParams.set(k, String(v));
|
||||
}
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { "User-Agent": UA, Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) throw new Error(`wiki ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
function pageUrl(title: string): string {
|
||||
return `${WIKI_BASE}/w/${encodeURIComponent(title.replace(/ /g, "_"))}`;
|
||||
}
|
||||
|
||||
function absolutize(url: string): string {
|
||||
if (url.startsWith("//")) return `https:${url}`;
|
||||
if (url.startsWith("/")) return `${WIKI_BASE}${url}`;
|
||||
return url;
|
||||
}
|
||||
|
||||
// Parse the rendered HTML of a Minecraft wiki page and pull out:
|
||||
// - the 3×3 input grid as [[url|null, …], …] (9 slots, in reading order)
|
||||
// - the result item URL (the contents of <span class="mcui-output">)
|
||||
// Returns null when the page has no `mcui-Crafting_Table` block (uncraftable,
|
||||
// or only smelting / brewing / smithing recipes, which use other mcui classes).
|
||||
function parseCraftingGrid(html: string): { grid: (string | null)[][]; result: string | null } | null {
|
||||
const tableMatch = html.match(/<table\b[^>]*data-description="Crafting recipes"[^>]*>([\s\S]*?)<\/table>/);
|
||||
if (!tableMatch) return null;
|
||||
const tableHtml = tableMatch[1];
|
||||
// The crafting widget: <span class="mcui mcui-Crafting_Table …"> … </span>. Find the input + output sections.
|
||||
const widget = tableHtml.match(/<span class="mcui mcui-Crafting[^"]*"[^>]*>([\s\S]*?)<\/span>\s*<\/div>/);
|
||||
if (!widget) return null;
|
||||
const w = widget[1];
|
||||
|
||||
// Each slot starts with `<span class="invslot">`. Splitting on that marker gives us one chunk per slot
|
||||
// (the first chunk is whatever preceded the first invslot — discarded). An empty slot's chunk starts
|
||||
// with `</span>` immediately; a filled slot's chunk contains an <img src="..."> first.
|
||||
const chunks = w.split('<span class="invslot">').slice(1);
|
||||
const allSlots: (string | null)[] = chunks.map((chunk) => {
|
||||
if (chunk.startsWith("</span>")) return null;
|
||||
const img = chunk.match(/<img\b[^>]*\bsrc="([^"]+)"/);
|
||||
return img ? absolutize(img[1]) : null;
|
||||
});
|
||||
// The output uses `invslot invslot-large`, but the split keys on `invslot">` so the trailing
|
||||
// -large slot is split too. The first 9 slots are the input grid; the next non-null is the
|
||||
// result (any "auxiliary" empty filler slots between input and output are tolerable to skip).
|
||||
if (allSlots.length < 9) return null;
|
||||
const inputs = allSlots.slice(0, 9);
|
||||
const result = allSlots.slice(9).find((s) => s != null) ?? null;
|
||||
return {
|
||||
grid: [inputs.slice(0, 3), inputs.slice(3, 6), inputs.slice(6, 9)],
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchBytes(url: string): Promise<Buffer> {
|
||||
const res = await fetch(url, {
|
||||
headers: { "User-Agent": UA },
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${url} → ${res.status}`);
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
// Compose a Minecraft-style crafting recipe PNG. Layout (each cell 64×64 px,
|
||||
// scaled up from the wiki's native 32 px for clarity on phones):
|
||||
// [grid 3×3] [arrow "→"] [result]
|
||||
// Background is the classic inventory gray (#8b8b8b) with darker slot borders.
|
||||
const CELL = 64;
|
||||
const GAP = 4;
|
||||
const PAD = 8;
|
||||
const ARROW_W = 56;
|
||||
async function composeRecipePng(grid: (string | null)[][], result: string | null): Promise<Buffer> {
|
||||
// Collect unique URLs and fetch each once.
|
||||
const unique = new Set<string>();
|
||||
for (const row of grid) for (const u of row) if (u) unique.add(u);
|
||||
if (result) unique.add(result);
|
||||
const blobs: Record<string, Buffer> = {};
|
||||
await Promise.all(
|
||||
[...unique].map(async (u) => {
|
||||
blobs[u] = await fetchBytes(u);
|
||||
}),
|
||||
);
|
||||
|
||||
const gridW = CELL * 3 + GAP * 2;
|
||||
const gridH = CELL * 3 + GAP * 2;
|
||||
const totalW = PAD + gridW + (result ? PAD + ARROW_W + PAD + CELL : 0) + PAD;
|
||||
const totalH = PAD + gridH + PAD;
|
||||
|
||||
// Resize every needed sprite to CELL×CELL up front, with nearest-neighbour so
|
||||
// the 16-pixel inventory icons stay crisp (no antialiasing). Sharp's input
|
||||
// is the original buffer; outputs are 64×64 PNG buffers.
|
||||
const sized: Record<string, Buffer> = {};
|
||||
await Promise.all(
|
||||
[...unique].map(async (u) => {
|
||||
sized[u] = await sharp(blobs[u]).resize(CELL, CELL, { kernel: "nearest" }).png().toBuffer();
|
||||
}),
|
||||
);
|
||||
|
||||
// Arrow rendered as SVG → PNG.
|
||||
const arrowSvg = Buffer.from(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${ARROW_W}" height="${CELL}" viewBox="0 0 ${ARROW_W} ${CELL}">` +
|
||||
`<text x="${ARROW_W / 2}" y="${CELL / 2 + 16}" text-anchor="middle" font-family="serif" font-size="48" fill="#222">→</text>` +
|
||||
`</svg>`,
|
||||
);
|
||||
const arrowPng = await sharp(arrowSvg).png().toBuffer();
|
||||
|
||||
const composites: { input: Buffer; top: number; left: number }[] = [];
|
||||
// Slot backgrounds (slightly darker squares to look like inventory cells).
|
||||
for (let r = 0; r < 3; r++) {
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const left = PAD + c * (CELL + GAP);
|
||||
const top = PAD + r * (CELL + GAP);
|
||||
composites.push({
|
||||
input: Buffer.from(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${CELL}" height="${CELL}">` +
|
||||
`<rect width="${CELL}" height="${CELL}" fill="#6f6f6f" stroke="#3b3b3b" stroke-width="2"/>` +
|
||||
`</svg>`,
|
||||
),
|
||||
top, left,
|
||||
});
|
||||
const u = grid[r][c];
|
||||
if (u) composites.push({ input: sized[u], top, left });
|
||||
}
|
||||
}
|
||||
if (result) {
|
||||
const ax = PAD + gridW + PAD;
|
||||
const rx = ax + ARROW_W + PAD;
|
||||
composites.push({ input: arrowPng, top: PAD + (gridH - CELL) / 2, left: ax });
|
||||
composites.push({
|
||||
input: Buffer.from(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${CELL}" height="${CELL}">` +
|
||||
`<rect width="${CELL}" height="${CELL}" fill="#6f6f6f" stroke="#3b3b3b" stroke-width="2"/>` +
|
||||
`</svg>`,
|
||||
),
|
||||
top: PAD + (gridH - CELL) / 2,
|
||||
left: rx,
|
||||
});
|
||||
composites.push({ input: sized[result], top: PAD + (gridH - CELL) / 2, left: rx });
|
||||
}
|
||||
|
||||
return sharp({
|
||||
create: { width: totalW, height: totalH, channels: 4, background: { r: 139, g: 139, b: 139, alpha: 1 } },
|
||||
})
|
||||
.composite(composites)
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
export const minecraftWikiTools: Tool<z.ZodRawShape>[] = [
|
||||
tool({
|
||||
name: "wiki_search",
|
||||
title: "Search the Minecraft wiki",
|
||||
description:
|
||||
"Search minecraft.wiki for pages matching a query. Returns top matches with title, short description, and canonical page URL. Use this first to find the exact page title before calling wiki_page or wiki_page_images.",
|
||||
parameters: {
|
||||
query: z.string().min(1).describe("Search terms, e.g. 'iron pickaxe', 'crafting table', 'beacon'"),
|
||||
limit: z.number().int().min(1).max(15).optional().describe("Max results (default 5)"),
|
||||
},
|
||||
handler: async ({ query, limit }) => {
|
||||
// opensearch returns a 4-tuple: [query, titles[], descriptions[], urls[]]
|
||||
type OpenSearch = [string, string[], string[], string[]];
|
||||
const data = await wikiGet<OpenSearch>({
|
||||
action: "opensearch",
|
||||
search: query,
|
||||
limit: limit ?? 5,
|
||||
namespace: 0,
|
||||
});
|
||||
const [, titles, descriptions, urls] = data;
|
||||
return {
|
||||
results: titles.map((title, i) => ({
|
||||
title,
|
||||
description: descriptions[i] || "",
|
||||
url: urls[i] || pageUrl(title),
|
||||
})),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
tool({
|
||||
name: "wiki_page",
|
||||
title: "Get a Minecraft wiki page summary",
|
||||
description:
|
||||
"Fetch the intro extract (plain text) plus the page's thumbnail image URL and canonical URL. Good for a one-line answer about an item, mob, or mechanic, plus a representative image to send.",
|
||||
parameters: {
|
||||
title: z.string().min(1).describe("Exact wiki page title (use wiki_search first if unsure)"),
|
||||
thumb_size: z.number().int().min(120).max(1200).optional().describe("Thumbnail pixel width (default 600)"),
|
||||
},
|
||||
handler: async ({ title, thumb_size }) => {
|
||||
type PageRes = {
|
||||
query?: {
|
||||
pages?: {
|
||||
title: string;
|
||||
missing?: boolean;
|
||||
extract?: string;
|
||||
thumbnail?: { source: string; width: number; height: number };
|
||||
pageimage?: string;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
const data = await wikiGet<PageRes>({
|
||||
action: "query",
|
||||
prop: "extracts|pageimages",
|
||||
titles: title,
|
||||
exintro: "1",
|
||||
explaintext: "1",
|
||||
exchars: 600,
|
||||
piprop: "thumbnail|name",
|
||||
pithumbsize: thumb_size ?? 600,
|
||||
redirects: "1",
|
||||
});
|
||||
const page = data.query?.pages?.[0];
|
||||
if (!page || page.missing) return { error: `no wiki page titled '${title}'` };
|
||||
return {
|
||||
title: page.title,
|
||||
extract: page.extract ?? "",
|
||||
thumbnail_url: page.thumbnail?.source ?? null,
|
||||
page_url: pageUrl(page.title),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
tool({
|
||||
name: "wiki_recipe_image",
|
||||
title: "Send a Minecraft crafting recipe image to a chat",
|
||||
description:
|
||||
"Generate a 3×3 crafting-grid image of the recipe (with the result item beside an arrow) and send it directly to a Telegram chat. Use this for 'send me the recipe' / 'image of recipe' requests instead of chaining wiki_recipe + tg_send_photo — minecraft.wiki has no standalone recipe PNG, so this tool composites one from the per-slot ingredient sprites. Returns { sent: true, message_id } on success. Fails if the page has no 3×3 crafting recipe (smelting, brewing, smithing all use different mcui widgets).",
|
||||
parameters: {
|
||||
title: z.string().min(1).describe("Page title — e.g. 'Anvil', 'Beacon', 'Crafting Table'"),
|
||||
chat: z.union([z.number().int(), z.string()]).describe("Telegram chat id to send the photo into"),
|
||||
caption: z.string().max(1024).optional().describe("Optional caption — defaults to the page title if omitted"),
|
||||
},
|
||||
handler: async ({ title, chat, caption }, { tg }) => {
|
||||
type ParseRes = { parse?: { title?: string; text?: string } };
|
||||
const data = await wikiGet<ParseRes>({
|
||||
action: "parse",
|
||||
page: title,
|
||||
prop: "text",
|
||||
redirects: "1",
|
||||
disableeditsection: "1",
|
||||
});
|
||||
const html = data.parse?.text ?? "";
|
||||
if (!html) return { error: `no wiki page titled '${title}'` };
|
||||
const resolved = data.parse?.title ?? title;
|
||||
const parsed = parseCraftingGrid(html);
|
||||
if (!parsed) {
|
||||
return {
|
||||
error:
|
||||
`no 3×3 crafting recipe on '${resolved}' (page may use smelting / brewing / smithing, or item is uncraftable)`,
|
||||
};
|
||||
}
|
||||
const png = await composeRecipePng(parsed.grid, parsed.result);
|
||||
const sent = await tg.sendPhotoBytes({
|
||||
chat_id: chat,
|
||||
bytes: png,
|
||||
filename: `${resolved.replace(/[^A-Za-z0-9._-]+/g, "_")}_recipe.png`,
|
||||
caption: caption ?? `${resolved} — crafting recipe`,
|
||||
});
|
||||
return { sent: true, message_id: sent.message_id, title: resolved };
|
||||
},
|
||||
}),
|
||||
|
||||
tool({
|
||||
name: "wiki_recipe",
|
||||
title: "Get a crafting recipe from the Minecraft wiki",
|
||||
description:
|
||||
"Extracts the crafting recipe ingredient list from a wiki page (parses the live recipe table — minecraft.wiki renders recipes as HTML grids, not standalone images, so wiki_page_images is the wrong tool for recipe queries). Returns the ingredients string for each recipe variant, plus the page's main item thumbnail URL to send with tg_send_photo. Use this whenever a user asks 'how do I craft X' or 'recipe for X'.",
|
||||
parameters: {
|
||||
title: z.string().min(1).describe("Exact wiki page title — e.g. 'Anvil', 'Beacon', 'Crafting Table'. Use wiki_search first if unsure."),
|
||||
},
|
||||
handler: async ({ title }) => {
|
||||
// 1. Parsed HTML — the recipe lives in a wikitable with data-description="Crafting recipes"
|
||||
type ParseRes = { parse?: { title?: string; text?: string } };
|
||||
const data = await wikiGet<ParseRes>({
|
||||
action: "parse",
|
||||
page: title,
|
||||
prop: "text",
|
||||
redirects: "1",
|
||||
disableeditsection: "1",
|
||||
});
|
||||
const html = data.parse?.text ?? "";
|
||||
if (!html) return { error: `no wiki page titled '${title}'` };
|
||||
const resolvedTitle = data.parse?.title ?? title;
|
||||
|
||||
// 2. Find each crafting-recipes table and pull the first <td> (ingredients) of each data row.
|
||||
const stripHtml = (s: string) =>
|
||||
s.replace(/<br\s*\/?>/gi, " + ")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/ | /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/\s*\+\s*/g, " + ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const tableRe = /<table\b[^>]*data-description="Crafting recipes"[^>]*>([\s\S]*?)<\/table>/g;
|
||||
const recipes: { ingredients: string }[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = tableRe.exec(html)) !== null) {
|
||||
const rowRe = /<tr\b[^>]*>([\s\S]*?)<\/tr>/g;
|
||||
let r: RegExpExecArray | null;
|
||||
while ((r = rowRe.exec(m[1])) !== null) {
|
||||
if (/<th\b/.test(r[1])) continue;
|
||||
const td = /<td\b[^>]*>([\s\S]*?)<\/td>/.exec(r[1]);
|
||||
if (!td) continue;
|
||||
const text = stripHtml(td[1]);
|
||||
if (text) recipes.push({ ingredients: text });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Pull the page thumbnail in a second query — gives the bot a URL to send.
|
||||
type ThumbRes = { query?: { pages?: { title: string; thumbnail?: { source: string } }[] } };
|
||||
const meta = await wikiGet<ThumbRes>({
|
||||
action: "query",
|
||||
prop: "pageimages",
|
||||
titles: resolvedTitle,
|
||||
piprop: "thumbnail",
|
||||
pithumbsize: 600,
|
||||
redirects: "1",
|
||||
});
|
||||
const thumbnail = meta.query?.pages?.[0]?.thumbnail?.source ?? null;
|
||||
|
||||
return {
|
||||
title: resolvedTitle,
|
||||
recipes, // [{ ingredients: "Block of Iron + Iron Ingot" }, ...]
|
||||
thumbnail_url: thumbnail, // page's main item image — pass to tg_send_photo
|
||||
page_url: pageUrl(resolvedTitle),
|
||||
note: recipes.length === 0
|
||||
? "No recipe table on this page — the item may be uncraftable, found only via loot/structure, or listed under a different page."
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
tool({
|
||||
name: "wiki_page_images",
|
||||
title: "List images on a Minecraft wiki page (with full URLs)",
|
||||
description:
|
||||
"Returns every image embedded on a wiki page along with its direct https URL. Use the optional `filter` to narrow by filename substring (case-insensitive) — pass 'recipe' or 'craft' or 'grid' to surface crafting-recipe diagrams. Hand a URL from this result to tg_send_photo to deliver the image into a chat.",
|
||||
parameters: {
|
||||
title: z.string().min(1).describe("Exact wiki page title"),
|
||||
filter: z.string().optional().describe("Case-insensitive substring filter on the filename (e.g. 'recipe', 'craft', 'grid')"),
|
||||
limit: z.number().int().min(1).max(50).optional().describe("Max images to return (default 20)"),
|
||||
},
|
||||
handler: async ({ title, filter, limit }) => {
|
||||
type ImgRes = {
|
||||
query?: {
|
||||
pages?: {
|
||||
title: string;
|
||||
imageinfo?: { url: string; mime?: string; width?: number; height?: number }[];
|
||||
}[];
|
||||
};
|
||||
};
|
||||
const data = await wikiGet<ImgRes>({
|
||||
action: "query",
|
||||
generator: "images",
|
||||
titles: title,
|
||||
prop: "imageinfo",
|
||||
iiprop: "url|mime|size",
|
||||
gimlimit: 50,
|
||||
redirects: "1",
|
||||
});
|
||||
const pages = data.query?.pages ?? [];
|
||||
let images = pages.map((p) => ({
|
||||
filename: p.title.replace(/^File:/, ""),
|
||||
url: p.imageinfo?.[0]?.url ?? "",
|
||||
mime: p.imageinfo?.[0]?.mime,
|
||||
})).filter((i) => i.url && (!i.mime || i.mime.startsWith("image/")));
|
||||
if (filter) {
|
||||
const needle = filter.toLowerCase();
|
||||
images = images.filter((i) => i.filename.toLowerCase().includes(needle));
|
||||
}
|
||||
return { images: images.slice(0, limit ?? 20) };
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -7,6 +7,19 @@ import type { Database } from "bun:sqlite";
|
||||
|
||||
export type TgClient = {
|
||||
call: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>;
|
||||
/**
|
||||
* Upload a binary photo to Telegram via multipart/form-data sendPhoto.
|
||||
* Use for synthesized images (e.g. composited recipe grids) that don't
|
||||
* live at a public URL Telegram can fetch itself.
|
||||
*/
|
||||
sendPhotoBytes: (params: {
|
||||
chat_id: number | string;
|
||||
bytes: Uint8Array;
|
||||
filename?: string;
|
||||
caption?: string;
|
||||
parse_mode?: "HTML" | "MarkdownV2";
|
||||
reply_to_message_id?: number;
|
||||
}) => Promise<{ message_id: number }>;
|
||||
};
|
||||
|
||||
export function createTgClient(token: string): TgClient {
|
||||
@@ -24,6 +37,24 @@ export function createTgClient(token: string): TgClient {
|
||||
if (!data.ok) throw new Error(`${method}: ${data.description} (code ${data.error_code})`);
|
||||
return data.result as T;
|
||||
},
|
||||
async sendPhotoBytes({ chat_id, bytes, filename = "photo.png", caption, parse_mode, reply_to_message_id }) {
|
||||
const fd = new FormData();
|
||||
fd.set("chat_id", String(chat_id));
|
||||
if (caption !== undefined) fd.set("caption", caption);
|
||||
if (parse_mode !== undefined) fd.set("parse_mode", parse_mode);
|
||||
if (reply_to_message_id !== undefined) {
|
||||
fd.set("reply_parameters", JSON.stringify({ message_id: reply_to_message_id }));
|
||||
}
|
||||
fd.set("photo", new Blob([bytes], { type: "image/png" }), filename);
|
||||
const res = await fetch(`${base}/sendPhoto`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const data = (await res.json()) as { ok: boolean; result?: { message_id: number }; description?: string; error_code?: number };
|
||||
if (!data.ok) throw new Error(`sendPhoto(bytes): ${data.description} (code ${data.error_code})`);
|
||||
return data.result!;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||
"sharp": "^0.33.5",
|
||||
"zod": "^3.23.8",
|
||||
"zod-to-json-schema": "^3.25.2"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user