Files
Minecraft-Server/mcp/lib/types.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

167 lines
5.7 KiB
TypeScript

// Shared types + helpers used by both telegram-tools and minecraft-tools.
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import type { Database } from "bun:sqlite";
// ---- Telegram HTTP client ----
export type TgClient = {
call: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>;
/**
* Upload a binary photo to Telegram via multipart/form-data sendPhoto.
* Use for synthesized images (e.g. composited recipe grids) that don't
* live at a public URL Telegram can fetch itself.
*/
sendPhotoBytes: (params: {
chat_id: number | string;
bytes: Uint8Array;
filename?: string;
caption?: string;
parse_mode?: "HTML" | "MarkdownV2";
reply_to_message_id?: number;
}) => Promise<{ message_id: number }>;
};
export function createTgClient(token: string): TgClient {
if (!token) throw new Error("createTgClient: empty token");
const base = `https://api.telegram.org/bot${token}`;
return {
async call<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
const res = await fetch(`${base}/${method}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
signal: AbortSignal.timeout(20_000),
});
const data = (await res.json()) as { ok: boolean; result?: T; description?: string; error_code?: number };
if (!data.ok) throw new Error(`${method}: ${data.description} (code ${data.error_code})`);
return data.result as T;
},
async sendPhotoBytes({ chat_id, bytes, filename = "photo.png", caption, parse_mode, reply_to_message_id }) {
const fd = new FormData();
fd.set("chat_id", String(chat_id));
if (caption !== undefined) fd.set("caption", caption);
if (parse_mode !== undefined) fd.set("parse_mode", parse_mode);
if (reply_to_message_id !== undefined) {
fd.set("reply_parameters", JSON.stringify({ message_id: reply_to_message_id }));
}
fd.set("photo", new Blob([bytes], { type: "image/png" }), filename);
const res = await fetch(`${base}/sendPhoto`, {
method: "POST",
body: fd,
signal: AbortSignal.timeout(30_000),
});
const data = (await res.json()) as { ok: boolean; result?: { message_id: number }; description?: string; error_code?: number };
if (!data.ok) throw new Error(`sendPhoto(bytes): ${data.description} (code ${data.error_code})`);
return data.result!;
},
};
}
// ---- Minecraft runtime (docker/host shell) ----
export type ShResult = { ok: boolean; code: number; out: string; err: string };
export type McConfig = {
mcDir: string;
composeFile: string;
composeProject: string;
serverSvc: string;
serverContainer: string;
pfSvc: string;
pfContainer: string;
backupSh: string;
};
export type McRuntime = {
config: McConfig;
sh: (cmd: string[]) => Promise<ShResult>;
compose: (...args: string[]) => Promise<ShResult>;
containerRunning: (name: string) => Promise<boolean>;
};
export function createMcRuntime(config: McConfig): McRuntime {
async function sh(cmd: string[]): Promise<ShResult> {
const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
const [out, err] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const code = await proc.exited;
return { ok: code === 0, code, out: out.trimEnd(), err: err.trimEnd() };
}
return {
config,
sh,
compose: (...args) => sh([
"docker", "compose",
"--project-directory", config.mcDir,
"-p", config.composeProject,
"-f", config.composeFile,
...args,
]),
async containerRunning(name) {
const r = await sh(["docker", "inspect", "-f", "{{.State.Running}}", name]);
return r.ok && r.out === "true";
},
};
}
// ---- Tool definition shape ----
export type ToolCtx = {
tg: TgClient;
db: Database;
mc: McRuntime;
};
export type Tool<Params extends z.ZodRawShape = z.ZodRawShape> = {
name: string;
title: string;
description: string;
parameters: Params;
handler: (args: z.infer<z.ZodObject<Params>>, ctx: ToolCtx) => Promise<unknown>;
/** Bot-side: only admins may invoke. MCP server ignores this (caller is trusted). */
requiresAdmin?: boolean;
};
// Strip per-tool shape so a heterogeneous catalog type-checks.
export function tool<P extends z.ZodRawShape>(t: Tool<P>): Tool<z.ZodRawShape> {
return t as Tool<z.ZodRawShape>;
}
// ---- Zod -> Gemini function declaration ----
function cleanForGemini(s: unknown): unknown {
if (Array.isArray(s)) return s.map(cleanForGemini);
if (s && typeof s === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(s as Record<string, unknown>)) {
if (k === "$schema" || k === "additionalProperties" || k === "default") continue;
// Gemini's schema dialect uses minimum/maximum only, not the exclusive variants.
if (k === "exclusiveMinimum" || k === "exclusiveMaximum") continue;
if (k === "anyOf" || k === "oneOf") {
const variants = (v as unknown[]).filter((x) => x && (x as Record<string, unknown>).type !== "null");
if (variants.length === 1) return cleanForGemini(variants[0]);
return { type: "string" };
}
out[k] = cleanForGemini(v);
}
return out;
}
return s;
}
export function toolToGeminiFunction(t: Tool<z.ZodRawShape>) {
const schema = zodToJsonSchema(z.object(t.parameters), {
$refStrategy: "none",
target: "openApi3",
});
const params = cleanForGemini(schema) as Record<string, unknown>;
return {
name: t.name,
description: t.description,
parameters: params.type ? params : { type: "object", properties: {} },
};
}