// 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(params: Record): Promise { 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 ) // 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(/]*data-description="Crafting recipes"[^>]*>([\s\S]*?)<\/table>/); if (!tableMatch) return null; const tableHtml = tableMatch[1]; // The crafting widget: . Find the input + output sections. const widget = tableHtml.match(/]*>([\s\S]*?)<\/span>\s*<\/div>/); if (!widget) return null; const w = widget[1]; // Each slot starts with ``. 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 `` immediately; a filled slot's chunk contains an first. const chunks = w.split('').slice(1); const allSlots: (string | null)[] = chunks.map((chunk) => { if (chunk.startsWith("")) return null; const img = chunk.match(/]*\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 { 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 { // Collect unique URLs and fetch each once. const unique = new Set(); for (const row of grid) for (const u of row) if (u) unique.add(u); if (result) unique.add(result); const blobs: Record = {}; 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 = {}; 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( `` + `` + ``, ); 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( `` + `` + ``, ), 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( `` + `` + ``, ), 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[] = [ 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({ 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({ 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({ 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({ 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 (ingredients) of each data row. const stripHtml = (s: string) => s.replace(//gi, " + ") .replace(/<[^>]+>/g, "") .replace(/ | /g, " ") .replace(/&/g, "&") .replace(/\s*\+\s*/g, " + ") .replace(/\s+/g, " ") .trim(); const tableRe = /]*data-description="Crafting recipes"[^>]*>([\s\S]*?)<\/table>/g; const recipes: { ingredients: string }[] = []; let m: RegExpExecArray | null; while ((m = tableRe.exec(html)) !== null) { const rowRe = /]*>([\s\S]*?)<\/tr>/g; let r: RegExpExecArray | null; while ((r = rowRe.exec(m[1])) !== null) { if (/]*>([\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({ 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({ 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) }; }, }), ];