136 lines
4.3 KiB
TypeScript
136 lines
4.3 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>;
|
||
|
|
};
|
||
|
|
|
||
|
|
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;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- 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: {} },
|
||
|
|
};
|
||
|
|
}
|