Adds a new MCP tool that parses minecraft.wiki's <table data-description=
"Crafting recipes"> directly, pulling the ingredients column out of each
row (e.g. "Block of Iron + Iron Ingot" for Anvil), then chases up the
page's main item thumbnail via prop=pageimages so the bot can deliver
text + a real image in one tool round.
Why a dedicated tool: minecraft.wiki does not host standalone "recipe.png"
files — recipes are rendered live as HTML grids composed from individual
BlockSprite icons. Filtering wiki_page_images for 'recipe' / 'craft' /
'grid' returned empty for every craftable item, leaving the model with
nothing to call tg_send_photo with. Confirmed by inspecting the Anvil
page's image inventory (Anvil1-6.png + GUI + sound files, no recipe).
Tool returns {
title, page_url,
recipes: [{ ingredients: "…" }, …], // strips <br>/<a>/ cleanly
thumbnail_url, // for tg_send_photo
note?, // present when no recipe rows
}
Persona prompt tightened:
- wiki_recipe is now the FIRST call for "how do I craft X" / "recipe for X"
- wiki_page_images is downgraded to "specific non-thumbnail images only"
- IMAGE DELIVERY MANDATORY clause: must end the turn with tg_send_photo
using a real URL or say plainly that no thumbnail was found — no more
describing-the-image-in-prose-and-stopping failures.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
217 lines
8.8 KiB
TypeScript
217 lines
8.8 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 { 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, "_"))}`;
|
|
}
|
|
|
|
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",
|
|
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) };
|
|
},
|
|
}),
|
|
];
|