Files

167 lines
5.7 KiB
TypeScript
Raw Permalink Normal View History

extract Minecraft tools to shared toolkit; gate Redstone replies to authorized senders Redstone previously only saw the 23 tg_* tools — it had no idea the Minecraft stack existed, so questions like "is the server up?" went unanswered. This change extracts the 15 Minecraft tools (lifecycle, rcon, backup, players, seen) into the same shared catalog the Telegram tools already used, so both Gemini and external MCP clients see them. mcp/lib/types.ts (new) holds the shared shape: Tool<P>, ToolCtx, createTgClient, createMcRuntime (a Bun.spawn-based wrapper for docker compose / docker exec), and toolToGeminiFunction (zod → Gemini schema, now also stripping exclusiveMinimum/Maximum since Gemini rejects them). mcp/lib/minecraft-tools.ts (new) is the catalog itself. Eight handlers are flagged requiresAdmin: rcon, backup, and all destructive player_* writes plus seen_list. mcp/server.ts trusts the caller (Claude / Paul on the host) and ignores the flag; bot/bot.ts honours it at dispatch time, returning {error: "...requires admin role..."} to Gemini so it can explain to the user instead of attempting the call. mcp/server.ts shrinks from 423 lines to 70 — a single loop over both catalogs replaces the hand-rolled registrations. bot/bot.ts wires both catalogs into the function declarations and adds the admin gate. It also gains a defensive re-check on every incoming group/DM text: the Redstone handler now does its own lookup of ctx.from.id against the users table and refuses to reply unless status='active'. This is belt-and-braces — the auth middleware already short-circuits unauthorized callers earlier in the chain, but with privacy-mode-off groups now feeding every message through, a future refactor that reorders middleware shouldn't be able to make Redstone respond to strangers. Drops thinkingConfig from the Gemini call (gemini-2.5-flash-lite rejects it outright with HTTP 400). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:19:27 +02:00
// 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>;
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
/**
* 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 }>;
extract Minecraft tools to shared toolkit; gate Redstone replies to authorized senders Redstone previously only saw the 23 tg_* tools — it had no idea the Minecraft stack existed, so questions like "is the server up?" went unanswered. This change extracts the 15 Minecraft tools (lifecycle, rcon, backup, players, seen) into the same shared catalog the Telegram tools already used, so both Gemini and external MCP clients see them. mcp/lib/types.ts (new) holds the shared shape: Tool<P>, ToolCtx, createTgClient, createMcRuntime (a Bun.spawn-based wrapper for docker compose / docker exec), and toolToGeminiFunction (zod → Gemini schema, now also stripping exclusiveMinimum/Maximum since Gemini rejects them). mcp/lib/minecraft-tools.ts (new) is the catalog itself. Eight handlers are flagged requiresAdmin: rcon, backup, and all destructive player_* writes plus seen_list. mcp/server.ts trusts the caller (Claude / Paul on the host) and ignores the flag; bot/bot.ts honours it at dispatch time, returning {error: "...requires admin role..."} to Gemini so it can explain to the user instead of attempting the call. mcp/server.ts shrinks from 423 lines to 70 — a single loop over both catalogs replaces the hand-rolled registrations. bot/bot.ts wires both catalogs into the function declarations and adds the admin gate. It also gains a defensive re-check on every incoming group/DM text: the Redstone handler now does its own lookup of ctx.from.id against the users table and refuses to reply unless status='active'. This is belt-and-braces — the auth middleware already short-circuits unauthorized callers earlier in the chain, but with privacy-mode-off groups now feeding every message through, a future refactor that reorders middleware shouldn't be able to make Redstone respond to strangers. Drops thinkingConfig from the Gemini call (gemini-2.5-flash-lite rejects it outright with HTTP 400). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:19:27 +02:00
};
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;
},
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
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!;
},
extract Minecraft tools to shared toolkit; gate Redstone replies to authorized senders Redstone previously only saw the 23 tg_* tools — it had no idea the Minecraft stack existed, so questions like "is the server up?" went unanswered. This change extracts the 15 Minecraft tools (lifecycle, rcon, backup, players, seen) into the same shared catalog the Telegram tools already used, so both Gemini and external MCP clients see them. mcp/lib/types.ts (new) holds the shared shape: Tool<P>, ToolCtx, createTgClient, createMcRuntime (a Bun.spawn-based wrapper for docker compose / docker exec), and toolToGeminiFunction (zod → Gemini schema, now also stripping exclusiveMinimum/Maximum since Gemini rejects them). mcp/lib/minecraft-tools.ts (new) is the catalog itself. Eight handlers are flagged requiresAdmin: rcon, backup, and all destructive player_* writes plus seen_list. mcp/server.ts trusts the caller (Claude / Paul on the host) and ignores the flag; bot/bot.ts honours it at dispatch time, returning {error: "...requires admin role..."} to Gemini so it can explain to the user instead of attempting the call. mcp/server.ts shrinks from 423 lines to 70 — a single loop over both catalogs replaces the hand-rolled registrations. bot/bot.ts wires both catalogs into the function declarations and adds the admin gate. It also gains a defensive re-check on every incoming group/DM text: the Redstone handler now does its own lookup of ctx.from.id against the users table and refuses to reply unless status='active'. This is belt-and-braces — the auth middleware already short-circuits unauthorized callers earlier in the chain, but with privacy-mode-off groups now feeding every message through, a future refactor that reorders middleware shouldn't be able to make Redstone respond to strangers. Drops thinkingConfig from the Gemini call (gemini-2.5-flash-lite rejects it outright with HTTP 400). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:19:27 +02:00
};
}
// ---- 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: {} },
};
}