Compare commits
12 Commits
backup-202
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 978211dc65 | |||
| 3647174c30 | |||
| 633a4443c2 | |||
| a9a8bbe3dd | |||
| aae9ddd429 | |||
| 7217e3553b | |||
| f1d4fb596a | |||
| 8ae89610f3 | |||
| 811bf582c6 | |||
| f05db39903 | |||
| 404fee3405 | |||
| b99c36c13c |
328
bot/bot.ts
328
bot/bot.ts
@@ -103,6 +103,11 @@ db.exec(`
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sticker_library_last_seen
|
CREATE INDEX IF NOT EXISTS idx_sticker_library_last_seen
|
||||||
ON sticker_library(last_seen DESC);
|
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.
|
// Idempotent migrations.
|
||||||
@@ -517,18 +522,24 @@ function fmtTs(): string {
|
|||||||
|
|
||||||
async function renderMain(ctx: Context) {
|
async function renderMain(ctx: Context) {
|
||||||
const [mc, pf] = await Promise.all([serverRunning(), pfRunning()]);
|
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 =
|
const text =
|
||||||
`<b>🎮 Minecraft Control</b>\n` +
|
`🎮 <b>Minecraft</b>\n` +
|
||||||
|
`${headline}\n` +
|
||||||
`\n` +
|
`\n` +
|
||||||
`server : ${mc ? "✅ up" : "⛔ down"}\n` +
|
`${dot(mc)} Server\n` +
|
||||||
`tunnel : ${pf ? "✅ up" : "⛔ down"}\n` +
|
`${dot(pf)} Tunnel\n` +
|
||||||
`\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()
|
const kb = new InlineKeyboard()
|
||||||
.text(mc && pf ? "⏹ Down All" : "▶ Up All", mc && pf ? "act:downall" : "act:upall").row()
|
.text(allUp ? "🛑 Stop" : "▶️ Start", allUp ? "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").text("🔄", "nav:main");
|
||||||
.text("📜 Logs", "nav:logs").text("👥 Users", "nav:users").row()
|
|
||||||
.text("🔄 Refresh", "nav:main");
|
|
||||||
return editPanel(ctx, "main", text, kb);
|
return editPanel(ctx, "main", text, kb);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,13 +597,174 @@ async function renderConfirm(ctx: Context, action: string, prompt: string) {
|
|||||||
return editPanel(ctx, "main", text, kb);
|
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 ----
|
// ---- Action helpers ----
|
||||||
async function answerToast(ctx: Context, text: string) {
|
async function answerToast(ctx: Context, text: string) {
|
||||||
if (ctx.callbackQuery) await ctx.answerCallbackQuery({ text }).catch(() => {});
|
if (ctx.callbackQuery) await ctx.answerCallbackQuery({ text }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function actUpAll(ctx: Context) {
|
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 answerToast(ctx, r.ok ? "starting…" : "failed");
|
||||||
await maybeSendSticker(ctx, r.ok ? "up" : "error");
|
await maybeSendSticker(ctx, r.ok ? "up" : "error");
|
||||||
await renderMain(ctx);
|
await renderMain(ctx);
|
||||||
@@ -648,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:srv") return void await renderLogs(ctx, "srv");
|
||||||
if (data === "act:logs:pf") return void await renderLogs(ctx, "pf");
|
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") {
|
if (data === "info:adduser") {
|
||||||
await ctx.answerCallbackQuery({ text: "use /adduser @username [name] (or numeric id, or @me)", show_alert: true });
|
await ctx.answerCallbackQuery({ text: "use /adduser @username [name] (or numeric id, or @me)", show_alert: true });
|
||||||
return;
|
return;
|
||||||
@@ -848,6 +1022,10 @@ bot.command("clear", async (ctx) => {
|
|||||||
).get(chatId)?.n ?? 0;
|
).get(chatId)?.n ?? 0;
|
||||||
db.query<unknown, [number]>("DELETE FROM messages WHERE chat_id = ?").run(chatId);
|
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.`);
|
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
|
// User management
|
||||||
@@ -977,6 +1155,14 @@ bot.command("teststicker", admin(async (ctx) => {
|
|||||||
await maybeSendSticker(ctx, event);
|
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
|
// History — last N messages in this chat
|
||||||
bot.command("history", admin(async (ctx) => {
|
bot.command("history", admin(async (ctx) => {
|
||||||
const n = Math.min(50, Math.max(5, Number.parseInt((ctx.match as string).trim(), 10) || 20));
|
const n = Math.min(50, Math.max(5, Number.parseInt((ctx.match as string).trim(), 10) || 20));
|
||||||
@@ -1048,6 +1234,7 @@ import {
|
|||||||
} from "/mc/mcp/lib/types.ts";
|
} from "/mc/mcp/lib/types.ts";
|
||||||
import { telegramTools } from "/mc/mcp/lib/telegram-tools.ts";
|
import { telegramTools } from "/mc/mcp/lib/telegram-tools.ts";
|
||||||
import { minecraftTools } from "/mc/mcp/lib/minecraft-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 tgClient = createTgClient(TOKEN);
|
||||||
const mcRuntime = createMcRuntime({
|
const mcRuntime = createMcRuntime({
|
||||||
@@ -1061,7 +1248,7 @@ const mcRuntime = createMcRuntime({
|
|||||||
backupSh: `${MC_DIR}/backup.sh`,
|
backupSh: `${MC_DIR}/backup.sh`,
|
||||||
});
|
});
|
||||||
const toolCtx: ToolCtx = { tg: tgClient, db, mc: mcRuntime };
|
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]));
|
const toolsByName = new Map(allTools.map((t) => [t.name, t]));
|
||||||
// Hidden from Redstone's view: emoji→sticker is handled by post-processing
|
// 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.
|
// the reply text, so the model never has to manage file_ids itself.
|
||||||
@@ -1089,6 +1276,20 @@ type GeminiPart =
|
|||||||
| { functionResponse: { name: string; response: Record<string, unknown> } };
|
| { functionResponse: { name: string; response: Record<string, unknown> } };
|
||||||
type GeminiContent = { role: "user" | "model"; parts: GeminiPart[] };
|
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.
|
// Pull the last n text messages from this chat for continuity.
|
||||||
const Q_chatHistory = db.query<
|
const Q_chatHistory = db.query<
|
||||||
{ direction: "in" | "out"; body: string | null },
|
{ direction: "in" | "out"; body: string | null },
|
||||||
@@ -1126,6 +1327,11 @@ function startTypingLoop(ctx: Context, chatId: number): () => void {
|
|||||||
return () => { stopped = true; };
|
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 {
|
function botAddressed(ctx: Context, botId: number, botUsername: string): boolean {
|
||||||
const m = ctx.message;
|
const m = ctx.message;
|
||||||
if (!m || !m.text) return false;
|
if (!m || !m.text) return false;
|
||||||
@@ -1138,6 +1344,7 @@ function botAddressed(ctx: Context, botId: number, botUsername: string): boolean
|
|||||||
if (slice.toLowerCase() === `@${botUsername.toLowerCase()}`) return true;
|
if (slice.toLowerCase() === `@${botUsername.toLowerCase()}`) return true;
|
||||||
} else if (e.type === "text_mention" && e.user?.id === botId) return true;
|
} else if (e.type === "text_mention" && e.user?.id === botId) return true;
|
||||||
}
|
}
|
||||||
|
if (PERSONA_NAME_RE.test(text)) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1191,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_*).",
|
"- 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).",
|
"- 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.",
|
"- 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.",
|
"- 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:",
|
"Stickers — drop emojis inline and the bot does the rest:",
|
||||||
@@ -1274,7 +1487,7 @@ async function callOpenRouterOnce(messages: ORMessage[]): Promise<ORResponse | n
|
|||||||
"X-Title": OPENROUTER_TITLE,
|
"X-Title": OPENROUTER_TITLE,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: OPENROUTER_MODEL,
|
model: currentOpenRouterModel(),
|
||||||
messages,
|
messages,
|
||||||
tools: openRouterTools,
|
tools: openRouterTools,
|
||||||
temperature: 0.6,
|
temperature: 0.6,
|
||||||
@@ -1293,6 +1506,57 @@ async function callOpenRouterOnce(messages: ORMessage[]): Promise<ORResponse | n
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// History is plain text-only (see recentTurns); either provider can render it.
|
||||||
function historyToOpenRouter(history: GeminiContent[]): ORMessage[] {
|
function historyToOpenRouter(history: GeminiContent[]): ORMessage[] {
|
||||||
return history.map((c) => ({
|
return history.map((c) => ({
|
||||||
@@ -1302,7 +1566,7 @@ function historyToOpenRouter(history: GeminiContent[]): ORMessage[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runRedstoneTurnViaOpenRouter(
|
async function runRedstoneTurnViaOpenRouter(
|
||||||
systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role,
|
systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role, chatId: number,
|
||||||
): Promise<TurnResult | null> {
|
): Promise<TurnResult | null> {
|
||||||
if (!OPENROUTER_API_KEY) return null;
|
if (!OPENROUTER_API_KEY) return null;
|
||||||
const messages: ORMessage[] = [
|
const messages: ORMessage[] = [
|
||||||
@@ -1311,7 +1575,7 @@ async function runRedstoneTurnViaOpenRouter(
|
|||||||
{ role: "user", content: userText },
|
{ role: "user", content: userText },
|
||||||
];
|
];
|
||||||
let ranTool = false;
|
let ranTool = false;
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 0; i < 6; i++) {
|
||||||
const data = await callOpenRouterOnce(messages);
|
const data = await callOpenRouterOnce(messages);
|
||||||
if (!data) return null; // hard failure — caller falls back to Gemini
|
if (!data) return null; // hard failure — caller falls back to Gemini
|
||||||
const msg = data.choices?.[0]?.message;
|
const msg = data.choices?.[0]?.message;
|
||||||
@@ -1339,11 +1603,14 @@ async function runRedstoneTurnViaOpenRouter(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await tool.handler(args, toolCtx);
|
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 };
|
response = { result };
|
||||||
ranTool = true;
|
ranTool = true;
|
||||||
|
console.info("[redstone:or] tool <-", tc.function.name, "ok");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
response = { error: (e as Error).message };
|
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) });
|
messages.push({ role: "tool", tool_call_id: tc.id, content: JSON.stringify(response) });
|
||||||
@@ -1353,11 +1620,11 @@ async function runRedstoneTurnViaOpenRouter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runRedstoneTurnViaGemini(
|
async function runRedstoneTurnViaGemini(
|
||||||
systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role,
|
systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role, chatId: number,
|
||||||
): Promise<TurnResult> {
|
): Promise<TurnResult> {
|
||||||
const contents: GeminiContent[] = [...history, { role: "user", parts: [{ text: userText }] }];
|
const contents: GeminiContent[] = [...history, { role: "user", parts: [{ text: userText }] }];
|
||||||
let ranTool = false;
|
let ranTool = false;
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 0; i < 6; i++) {
|
||||||
const data = await callGeminiOnce(contents, systemPrompt);
|
const data = await callGeminiOnce(contents, systemPrompt);
|
||||||
if (!data) return { text: null, ranTool };
|
if (!data) return { text: null, ranTool };
|
||||||
const parts = data.candidates?.[0]?.content?.parts ?? [];
|
const parts = data.candidates?.[0]?.content?.parts ?? [];
|
||||||
@@ -1385,11 +1652,14 @@ async function runRedstoneTurnViaGemini(
|
|||||||
response = { error: `tool '${fc.functionCall.name}' requires admin role; the calling user is '${callerRole}'. Inform the user, do not retry.` };
|
response = { error: `tool '${fc.functionCall.name}' requires admin role; the calling user is '${callerRole}'. Inform the user, do not retry.` };
|
||||||
} else {
|
} else {
|
||||||
try {
|
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 };
|
response = { result };
|
||||||
ranTool = true;
|
ranTool = true;
|
||||||
|
console.info("[redstone:gem] tool <-", fc.functionCall.name, "ok");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
response = { error: (e as Error).message };
|
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 } });
|
responseParts.push({ functionResponse: { name: fc.functionCall.name, response } });
|
||||||
@@ -1399,13 +1669,13 @@ async function runRedstoneTurnViaGemini(
|
|||||||
return { text: null, ranTool };
|
return { text: null, ranTool };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role): Promise<TurnResult> {
|
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
|
// OpenRouter `openrouter/free` is the primary path — picks the best free
|
||||||
// tool-calling model automatically. Falls through to Gemini on null (no key,
|
// tool-calling model automatically. Falls through to Gemini on null (no key,
|
||||||
// network error, upstream rejected tool use).
|
// network error, upstream rejected tool use).
|
||||||
const orResult = await runRedstoneTurnViaOpenRouter(systemPrompt, history, userText, callerRole);
|
const orResult = await runRedstoneTurnViaOpenRouter(systemPrompt, history, userText, callerRole, chatId);
|
||||||
if (orResult !== null) return orResult;
|
if (orResult !== null) return orResult;
|
||||||
return runRedstoneTurnViaGemini(systemPrompt, history, userText, callerRole);
|
return runRedstoneTurnViaGemini(systemPrompt, history, userText, callerRole, chatId);
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.on("message:text", async (ctx) => {
|
bot.on("message:text", async (ctx) => {
|
||||||
@@ -1444,7 +1714,7 @@ bot.on("message:text", async (ctx) => {
|
|||||||
const sys = redstonePersona(user, chatType, chatId, srv);
|
const sys = redstonePersona(user, chatType, chatId, srv);
|
||||||
const latest = Q_latestMsgId.get(chatId)?.id ?? 0;
|
const latest = Q_latestMsgId.get(chatId)?.id ?? 0;
|
||||||
const history = recentTurns(chatId, latest, 6);
|
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;
|
reply = result.text;
|
||||||
ranTool = result.ranTool;
|
ranTool = result.ranTool;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1452,7 +1722,21 @@ bot.on("message:text", async (ctx) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!reply) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grammyjs/auto-retry": "^2.0.2",
|
"@grammyjs/auto-retry": "^2.0.2",
|
||||||
"@grammyjs/stream": "^1.0.1",
|
"@grammyjs/stream": "^1.0.1",
|
||||||
"grammy": "^1.30.0"
|
"grammy": "^1.30.0",
|
||||||
|
"sharp": "^0.33.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
|||||||
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 = {
|
export type TgClient = {
|
||||||
call: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>;
|
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 {
|
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})`);
|
if (!data.ok) throw new Error(`${method}: ${data.description} (code ${data.error_code})`);
|
||||||
return data.result as T;
|
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": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"zod-to-json-schema": "^3.25.2"
|
"zod-to-json-schema": "^3.25.2"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user