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>
393 lines
17 KiB
TypeScript
393 lines
17 KiB
TypeScript
// 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) };
|
||
},
|
||
}),
|
||
];
|