// 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 { 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, "_"))}`; } 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", 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) }; }, }), ];