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: {} },
|
|
|
|
|
};
|
|
|
|
|
}
|