// 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: (method: string, params?: Record) => Promise; /** * 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(method: string, params: Record = {}): Promise { 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; compose: (...args: string[]) => Promise; containerRunning: (name: string) => Promise; }; export function createMcRuntime(config: McConfig): McRuntime { async function sh(cmd: string[]): Promise { 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 = { name: string; title: string; description: string; parameters: Params; handler: (args: z.infer>, ctx: ToolCtx) => Promise; /** 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

(t: Tool

): Tool { return t as Tool; } // ---- 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 = {}; for (const [k, v] of Object.entries(s as Record)) { 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).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) { const schema = zodToJsonSchema(z.object(t.parameters), { $refStrategy: "none", target: "openApi3", }); const params = cleanForGemini(schema) as Record; return { name: t.name, description: t.description, parameters: params.type ? params : { type: "object", properties: {} }, }; }