Files
Minecraft-Server/mcp/lib/minecraft-wiki-tools.ts
Paul Kloppers 7217e3553b feat(redstone): wiki_recipe_image — composite real recipe PNG and upload
Previous wiki_recipe only returned the page thumbnail, so "send me the
recipe image" delivered a picture of the item, not the recipe itself.
minecraft.wiki renders recipes as live HTML grids, not standalone PNGs,
so we now generate the image ourselves.

mcp/lib/minecraft-wiki-tools.ts:
- parseCraftingGrid(html): pulls the 3×3 input grid + result item URL
  from <span class="mcui mcui-Crafting_Table">. Each <span class="invslot">
  is either empty (chunk starts with </span>) or has an <img src="/images/
  Invicon_X.png?h">. Reading-order chunks → grid; first non-null after
  the 9th input slot → result.
- composeRecipePng(grid, result): fetches each unique sprite, resizes
  to 64×64 via sharp (nearest-neighbour to keep pixel art crisp),
  composites onto a #8b8b8b inventory-style background with #6f6f6f
  slot squares + dark borders, places an SVG-rendered "→" arrow,
  then the result cell next to it. PNG buffer returned.
- New tool wiki_recipe_image(title, chat, caption?) that runs the
  pipeline end-to-end and uploads via tg.sendPhotoBytes — one tool
  call, image already in the chat when it returns.

mcp/lib/types.ts:
- TgClient gains sendPhotoBytes({ chat_id, bytes, filename?, caption?,
  parse_mode?, reply_to_message_id? }). Multipart/form-data POST to
  sendPhoto, since Telegram's URL-mode can only fetch public URLs and
  the composited PNG only lives in memory. 30s timeout (vs the 20s on
  the JSON call) because uploads are bigger.

mcp/package.json + bot/package.json: add sharp ^0.33.5. mcp/ pulls it
in too so /mc/mcp/lib resolves it at the bot's runtime (the bot's
node_modules is at /app while the lib files live at /mc/mcp/lib).

Persona prompt: wiki_recipe_image is now the FIRST call for any
"recipe" / "send me the recipe image" request. Single tool turn ends
with the photo already in the chat. wiki_recipe stays as a fallback
when the page has no 3×3 crafting widget (smelting / brewing / smithing
use different mcui classes).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:54:20 +02:00

393 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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(/&#160;|&nbsp;/g, " ")
.replace(/&amp;/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) };
},
}),
];