462 lines
19 KiB
TypeScript
462 lines
19 KiB
TypeScript
|
|
// Shared Telegram-action toolkit.
|
||
|
|
// Imported by mcp/server.ts (registers each as an MCP tool) and by bot/bot.ts
|
||
|
|
// (exposes each as a Gemini function declaration and dispatches calls).
|
||
|
|
// Imports of npm packages resolve via this directory's node_modules (i.e. mcp/node_modules)
|
||
|
|
// regardless of which process loads the file.
|
||
|
|
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;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Tool context passed to every handler ----
|
||
|
|
|
||
|
|
export type ToolCtx = {
|
||
|
|
tg: TgClient;
|
||
|
|
db: Database;
|
||
|
|
};
|
||
|
|
|
||
|
|
// ---- Tool definition shape ----
|
||
|
|
|
||
|
|
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>;
|
||
|
|
};
|
||
|
|
|
||
|
|
// ---- Shared sub-schemas ----
|
||
|
|
|
||
|
|
const chatField = z.union([z.number().int(), z.string().min(1)])
|
||
|
|
.describe("Telegram chat id (integer) OR a public @username string.");
|
||
|
|
|
||
|
|
const ParseMode = z.enum(["HTML", "MarkdownV2", "Markdown"]).optional();
|
||
|
|
|
||
|
|
const ButtonSchema = z.object({
|
||
|
|
text: z.string().min(1).max(64),
|
||
|
|
url: z.string().url().optional(),
|
||
|
|
callback_data: z.string().max(64).optional(),
|
||
|
|
copy_text: z.string().max(256).optional(),
|
||
|
|
switch_inline_query: z.string().optional(),
|
||
|
|
switch_inline_query_current_chat: z.string().optional(),
|
||
|
|
});
|
||
|
|
|
||
|
|
function buildKeyboard(rows: z.infer<typeof ButtonSchema>[][] | undefined) {
|
||
|
|
if (!rows || rows.length === 0) return undefined;
|
||
|
|
return {
|
||
|
|
inline_keyboard: rows.map((row) =>
|
||
|
|
row.map((b) => {
|
||
|
|
const out: Record<string, unknown> = { text: b.text };
|
||
|
|
if (b.url) out.url = b.url;
|
||
|
|
if (b.callback_data) out.callback_data = b.callback_data;
|
||
|
|
if (b.copy_text) out.copy_text = { text: b.copy_text };
|
||
|
|
if (b.switch_inline_query !== undefined) out.switch_inline_query = b.switch_inline_query;
|
||
|
|
if (b.switch_inline_query_current_chat !== undefined) out.switch_inline_query_current_chat = b.switch_inline_query_current_chat;
|
||
|
|
return out;
|
||
|
|
}),
|
||
|
|
),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- The tool catalog ----
|
||
|
|
|
||
|
|
// Helper to keep the array strictly typed without losing each tool's specific param shape.
|
||
|
|
function tool<P extends z.ZodRawShape>(t: Tool<P>): Tool<z.ZodRawShape> {
|
||
|
|
return t as Tool<z.ZodRawShape>;
|
||
|
|
}
|
||
|
|
|
||
|
|
export const telegramTools: Tool<z.ZodRawShape>[] = [
|
||
|
|
// ---- discovery ----
|
||
|
|
tool({
|
||
|
|
name: "tg_me",
|
||
|
|
title: "Bot identity",
|
||
|
|
description: "Return basic info about the bot — id, username, name, capability flags.",
|
||
|
|
parameters: {},
|
||
|
|
handler: async (_a, { tg }) => tg.call("getMe"),
|
||
|
|
}),
|
||
|
|
tool({
|
||
|
|
name: "tg_resolve_chat",
|
||
|
|
title: "Resolve a user to a DM chat id",
|
||
|
|
description: "Look up a user in users.db and return their telegram_id (== their DM chat_id). Provide exactly one of telegram_id, minecraft_name, telegram_username.",
|
||
|
|
parameters: {
|
||
|
|
telegram_id: z.number().int().optional(),
|
||
|
|
minecraft_name: z.string().optional(),
|
||
|
|
telegram_username: z.string().optional(),
|
||
|
|
},
|
||
|
|
handler: async ({ telegram_id, minecraft_name, telegram_username }, { db }) => {
|
||
|
|
const n = [telegram_id, minecraft_name, telegram_username].filter((v) => v !== undefined).length;
|
||
|
|
if (n !== 1) throw new Error("Provide exactly one of telegram_id, minecraft_name, telegram_username.");
|
||
|
|
const sql = "SELECT telegram_id, name, role, minecraft_name, username, status FROM users WHERE " +
|
||
|
|
(telegram_id !== undefined ? "telegram_id = ?"
|
||
|
|
: minecraft_name !== undefined ? "minecraft_name = ?"
|
||
|
|
: "username = ?");
|
||
|
|
const arg = telegram_id ?? minecraft_name ?? telegram_username!.replace(/^@/, "");
|
||
|
|
const row = db.prepare(sql).get(arg) as { telegram_id: number } | undefined;
|
||
|
|
if (!row) throw new Error("Not found in users table.");
|
||
|
|
return { chat_id: row.telegram_id, user: row };
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
tool({
|
||
|
|
name: "tg_known_chats",
|
||
|
|
title: "Known chats",
|
||
|
|
description: "List chat_ids the bot has ever sent or received a message in — find group chat_ids here.",
|
||
|
|
parameters: {},
|
||
|
|
handler: async (_a, { db }) => ({
|
||
|
|
chats: db.prepare(
|
||
|
|
"SELECT chat_id, COUNT(*) AS n, MAX(created_at) AS last_at FROM messages GROUP BY chat_id ORDER BY last_at DESC",
|
||
|
|
).all(),
|
||
|
|
}),
|
||
|
|
}),
|
||
|
|
tool({
|
||
|
|
name: "tg_get_chat",
|
||
|
|
title: "Get chat info",
|
||
|
|
description: "Telegram getChat — full info about a chat (type, title, description, photo, member count for groups).",
|
||
|
|
parameters: { chat: chatField },
|
||
|
|
handler: async ({ chat }, { tg }) => tg.call("getChat", { chat_id: chat }),
|
||
|
|
}),
|
||
|
|
tool({
|
||
|
|
name: "tg_get_chat_admins",
|
||
|
|
title: "List chat admins",
|
||
|
|
description: "Telegram getChatAdministrators — list admin members of a group/supergroup/channel.",
|
||
|
|
parameters: { chat: chatField },
|
||
|
|
handler: async ({ chat }, { tg }) => tg.call("getChatAdministrators", { chat_id: chat }),
|
||
|
|
}),
|
||
|
|
|
||
|
|
// ---- messaging ----
|
||
|
|
tool({
|
||
|
|
name: "tg_send_message",
|
||
|
|
title: "Send a text message",
|
||
|
|
description: "sendMessage. Supports HTML/Markdown, quote-replies, silent sends, link-preview suppression, and inline keyboards.",
|
||
|
|
parameters: {
|
||
|
|
chat: chatField,
|
||
|
|
text: z.string().min(1).max(4096),
|
||
|
|
parse_mode: ParseMode,
|
||
|
|
reply_to_message_id: z.number().int().optional(),
|
||
|
|
disable_notification: z.boolean().optional(),
|
||
|
|
disable_link_preview: z.boolean().optional(),
|
||
|
|
protect_content: z.boolean().optional(),
|
||
|
|
buttons: z.array(z.array(ButtonSchema)).optional(),
|
||
|
|
message_thread_id: z.number().int().optional(),
|
||
|
|
},
|
||
|
|
handler: async (a, { tg }) => {
|
||
|
|
const p: Record<string, unknown> = {
|
||
|
|
chat_id: a.chat, text: a.text, parse_mode: a.parse_mode,
|
||
|
|
disable_notification: a.disable_notification, protect_content: a.protect_content,
|
||
|
|
message_thread_id: a.message_thread_id,
|
||
|
|
};
|
||
|
|
if (a.reply_to_message_id !== undefined) p.reply_parameters = { message_id: a.reply_to_message_id };
|
||
|
|
if (a.disable_link_preview) p.link_preview_options = { is_disabled: true };
|
||
|
|
const kb = buildKeyboard(a.buttons);
|
||
|
|
if (kb) p.reply_markup = kb;
|
||
|
|
return tg.call("sendMessage", p);
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
tool({
|
||
|
|
name: "tg_edit_message",
|
||
|
|
title: "Edit a message",
|
||
|
|
description: "editMessageText — replace text + keyboard of a previously-sent message.",
|
||
|
|
parameters: {
|
||
|
|
chat: chatField,
|
||
|
|
message_id: z.number().int(),
|
||
|
|
text: z.string().min(1).max(4096),
|
||
|
|
parse_mode: ParseMode,
|
||
|
|
disable_link_preview: z.boolean().optional(),
|
||
|
|
buttons: z.array(z.array(ButtonSchema)).optional(),
|
||
|
|
},
|
||
|
|
handler: async (a, { tg }) => {
|
||
|
|
const p: Record<string, unknown> = { chat_id: a.chat, message_id: a.message_id, text: a.text, parse_mode: a.parse_mode };
|
||
|
|
if (a.disable_link_preview) p.link_preview_options = { is_disabled: true };
|
||
|
|
const kb = buildKeyboard(a.buttons);
|
||
|
|
if (kb) p.reply_markup = kb;
|
||
|
|
return tg.call("editMessageText", p);
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
tool({
|
||
|
|
name: "tg_delete_message",
|
||
|
|
title: "Delete a message",
|
||
|
|
description: "deleteMessage. Bots can always delete their own messages; others require admin rights.",
|
||
|
|
parameters: { chat: chatField, message_id: z.number().int() },
|
||
|
|
handler: async ({ chat, message_id }, { tg }) => ({ deleted: await tg.call("deleteMessage", { chat_id: chat, message_id }) }),
|
||
|
|
}),
|
||
|
|
tool({
|
||
|
|
name: "tg_pin_message",
|
||
|
|
title: "Pin a message",
|
||
|
|
description: "pinChatMessage. Needs admin rights in groups.",
|
||
|
|
parameters: {
|
||
|
|
chat: chatField,
|
||
|
|
message_id: z.number().int(),
|
||
|
|
disable_notification: z.boolean().optional(),
|
||
|
|
},
|
||
|
|
handler: async ({ chat, message_id, disable_notification }, { tg }) =>
|
||
|
|
({ pinned: await tg.call("pinChatMessage", { chat_id: chat, message_id, disable_notification }) }),
|
||
|
|
}),
|
||
|
|
tool({
|
||
|
|
name: "tg_unpin_message",
|
||
|
|
title: "Unpin a message",
|
||
|
|
description: "unpinChatMessage. Omit message_id to unpin the most recent pin.",
|
||
|
|
parameters: { chat: chatField, message_id: z.number().int().optional() },
|
||
|
|
handler: async ({ chat, message_id }, { tg }) =>
|
||
|
|
({ unpinned: await tg.call("unpinChatMessage", { chat_id: chat, message_id }) }),
|
||
|
|
}),
|
||
|
|
tool({
|
||
|
|
name: "tg_forward_message",
|
||
|
|
title: "Forward a message",
|
||
|
|
description: "forwardMessage — relay a message between chats, preserving original author attribution.",
|
||
|
|
parameters: {
|
||
|
|
from_chat: chatField,
|
||
|
|
to_chat: chatField,
|
||
|
|
message_id: z.number().int(),
|
||
|
|
disable_notification: z.boolean().optional(),
|
||
|
|
},
|
||
|
|
handler: async ({ from_chat, to_chat, message_id, disable_notification }, { tg }) =>
|
||
|
|
tg.call("forwardMessage", { from_chat_id: from_chat, chat_id: to_chat, message_id, disable_notification }),
|
||
|
|
}),
|
||
|
|
|
||
|
|
// ---- interactivity ----
|
||
|
|
tool({
|
||
|
|
name: "tg_chat_action",
|
||
|
|
title: "Show a busy indicator",
|
||
|
|
description: "sendChatAction — show 'typing…' or similar for ~5s. Re-call to extend.",
|
||
|
|
parameters: {
|
||
|
|
chat: chatField,
|
||
|
|
action: z.enum([
|
||
|
|
"typing", "upload_photo", "record_video", "upload_video", "record_voice", "upload_voice",
|
||
|
|
"upload_document", "choose_sticker", "find_location", "record_video_note", "upload_video_note",
|
||
|
|
]),
|
||
|
|
message_thread_id: z.number().int().optional(),
|
||
|
|
},
|
||
|
|
handler: async ({ chat, action, message_thread_id }, { tg }) =>
|
||
|
|
({ shown: await tg.call("sendChatAction", { chat_id: chat, action, message_thread_id }) }),
|
||
|
|
}),
|
||
|
|
tool({
|
||
|
|
name: "tg_react",
|
||
|
|
title: "React to a message",
|
||
|
|
description: "setMessageReaction — attach emoji reactions or clear them by passing emojis=[].",
|
||
|
|
parameters: {
|
||
|
|
chat: chatField,
|
||
|
|
message_id: z.number().int(),
|
||
|
|
emojis: z.array(z.string().min(1).max(8)).max(11)
|
||
|
|
.describe("Emoji to apply. Pass [] to clear all reactions."),
|
||
|
|
is_big: z.boolean().optional(),
|
||
|
|
},
|
||
|
|
handler: async ({ chat, message_id, emojis, is_big }, { tg }) => {
|
||
|
|
const reaction = emojis.map((e) => ({ type: "emoji" as const, emoji: e }));
|
||
|
|
return { reacted: await tg.call("setMessageReaction", { chat_id: chat, message_id, reaction, is_big }) };
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
tool({
|
||
|
|
name: "tg_send_poll",
|
||
|
|
title: "Send a poll",
|
||
|
|
description: "sendPoll — regular or quiz-style. For quiz, set type='quiz' and correct_option_id (0-based).",
|
||
|
|
parameters: {
|
||
|
|
chat: chatField,
|
||
|
|
question: z.string().min(1).max(300),
|
||
|
|
options: z.array(z.string().min(1).max(100)).min(2).max(12),
|
||
|
|
type: z.enum(["regular", "quiz"]).default("regular"),
|
||
|
|
is_anonymous: z.boolean().default(true),
|
||
|
|
allows_multiple_answers: z.boolean().optional(),
|
||
|
|
correct_option_id: z.number().int().min(0).optional(),
|
||
|
|
explanation: z.string().max(200).optional(),
|
||
|
|
open_period: z.number().int().min(5).max(600).optional(),
|
||
|
|
},
|
||
|
|
handler: async (a, { tg }) => tg.call("sendPoll", {
|
||
|
|
chat_id: a.chat, question: a.question,
|
||
|
|
options: a.options.map((text) => ({ text })),
|
||
|
|
type: a.type, is_anonymous: a.is_anonymous,
|
||
|
|
allows_multiple_answers: a.allows_multiple_answers,
|
||
|
|
correct_option_id: a.correct_option_id,
|
||
|
|
explanation: a.explanation, open_period: a.open_period,
|
||
|
|
}),
|
||
|
|
}),
|
||
|
|
tool({
|
||
|
|
name: "tg_send_dice",
|
||
|
|
title: "Send a dice / slot / dart",
|
||
|
|
description: "sendDice — animated random emoji. Telegram decides the outcome; check result.dice.value.",
|
||
|
|
parameters: {
|
||
|
|
chat: chatField,
|
||
|
|
emoji: z.enum(["🎲", "🎯", "🏀", "⚽", "🎳", "🎰"]).default("🎲"),
|
||
|
|
},
|
||
|
|
handler: async ({ chat, emoji }, { tg }) => tg.call("sendDice", { chat_id: chat, emoji }),
|
||
|
|
}),
|
||
|
|
|
||
|
|
// ---- rich content ----
|
||
|
|
tool({
|
||
|
|
name: "tg_send_photo",
|
||
|
|
title: "Send a photo",
|
||
|
|
description: "sendPhoto — pass an HTTPS URL Telegram can fetch, or a previously-uploaded file_id.",
|
||
|
|
parameters: {
|
||
|
|
chat: chatField,
|
||
|
|
photo: z.string().min(1).describe("HTTPS URL or Telegram file_id."),
|
||
|
|
caption: z.string().max(1024).optional(),
|
||
|
|
parse_mode: ParseMode,
|
||
|
|
has_spoiler: z.boolean().optional(),
|
||
|
|
disable_notification: z.boolean().optional(),
|
||
|
|
reply_to_message_id: z.number().int().optional(),
|
||
|
|
buttons: z.array(z.array(ButtonSchema)).optional(),
|
||
|
|
},
|
||
|
|
handler: async (a, { tg }) => {
|
||
|
|
const p: Record<string, unknown> = {
|
||
|
|
chat_id: a.chat, photo: a.photo, caption: a.caption,
|
||
|
|
parse_mode: a.parse_mode, has_spoiler: a.has_spoiler, disable_notification: a.disable_notification,
|
||
|
|
};
|
||
|
|
if (a.reply_to_message_id !== undefined) p.reply_parameters = { message_id: a.reply_to_message_id };
|
||
|
|
const kb = buildKeyboard(a.buttons);
|
||
|
|
if (kb) p.reply_markup = kb;
|
||
|
|
return tg.call("sendPhoto", p);
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
tool({
|
||
|
|
name: "tg_send_sticker",
|
||
|
|
title: "Send a sticker",
|
||
|
|
description: "sendSticker — pass a sticker file_id (see tg_stickers_known).",
|
||
|
|
parameters: {
|
||
|
|
chat: chatField,
|
||
|
|
file_id: z.string().min(1),
|
||
|
|
reply_to_message_id: z.number().int().optional(),
|
||
|
|
disable_notification: z.boolean().optional(),
|
||
|
|
},
|
||
|
|
handler: async ({ chat, file_id, reply_to_message_id, disable_notification }, { tg }) => {
|
||
|
|
const p: Record<string, unknown> = { chat_id: chat, sticker: file_id, disable_notification };
|
||
|
|
if (reply_to_message_id !== undefined) p.reply_parameters = { message_id: reply_to_message_id };
|
||
|
|
return tg.call("sendSticker", p);
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
tool({
|
||
|
|
name: "tg_stickers_known",
|
||
|
|
title: "List bot-bound stickers",
|
||
|
|
description: "Return event→file_id mappings the bot stored via /setsticker.",
|
||
|
|
parameters: {},
|
||
|
|
handler: async (_a, { db }) => {
|
||
|
|
const rows = db.prepare("SELECT event, file_id FROM stickers ORDER BY event").all();
|
||
|
|
return { count: rows.length, stickers: rows };
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
|
||
|
|
// ---- forum topics ----
|
||
|
|
tool({
|
||
|
|
name: "tg_create_topic",
|
||
|
|
title: "Create a forum topic",
|
||
|
|
description: "createForumTopic — only in forum-mode supergroups. Returns message_thread_id.",
|
||
|
|
parameters: {
|
||
|
|
chat: chatField,
|
||
|
|
name: z.string().min(1).max(128),
|
||
|
|
icon_color: z.number().int().optional(),
|
||
|
|
},
|
||
|
|
handler: async ({ chat, name, icon_color }, { tg }) =>
|
||
|
|
tg.call("createForumTopic", { chat_id: chat, name, icon_color }),
|
||
|
|
}),
|
||
|
|
tool({
|
||
|
|
name: "tg_close_topic",
|
||
|
|
title: "Close a forum topic",
|
||
|
|
description: "closeForumTopic.",
|
||
|
|
parameters: { chat: chatField, message_thread_id: z.number().int() },
|
||
|
|
handler: async ({ chat, message_thread_id }, { tg }) =>
|
||
|
|
({ closed: await tg.call("closeForumTopic", { chat_id: chat, message_thread_id }) }),
|
||
|
|
}),
|
||
|
|
tool({
|
||
|
|
name: "tg_reopen_topic",
|
||
|
|
title: "Reopen a forum topic",
|
||
|
|
description: "reopenForumTopic.",
|
||
|
|
parameters: { chat: chatField, message_thread_id: z.number().int() },
|
||
|
|
handler: async ({ chat, message_thread_id }, { tg }) =>
|
||
|
|
({ reopened: await tg.call("reopenForumTopic", { chat_id: chat, message_thread_id }) }),
|
||
|
|
}),
|
||
|
|
|
||
|
|
// ---- bot config ----
|
||
|
|
tool({
|
||
|
|
name: "tg_set_chat_title",
|
||
|
|
title: "Set a chat title",
|
||
|
|
description: "setChatTitle — requires bot admin rights.",
|
||
|
|
parameters: { chat: chatField, title: z.string().min(1).max(128) },
|
||
|
|
handler: async ({ chat, title }, { tg }) =>
|
||
|
|
({ updated: await tg.call("setChatTitle", { chat_id: chat, title }) }),
|
||
|
|
}),
|
||
|
|
tool({
|
||
|
|
name: "tg_set_my_commands",
|
||
|
|
title: "Set the bot's slash-command menu",
|
||
|
|
description: "setMyCommands. Scope=default for the global menu; pass `chat` for a single chat.",
|
||
|
|
parameters: {
|
||
|
|
commands: z.array(z.object({
|
||
|
|
command: z.string().min(1).max(32),
|
||
|
|
description: z.string().min(1).max(256),
|
||
|
|
})).min(0).max(100),
|
||
|
|
scope: z.enum([
|
||
|
|
"default", "all_private_chats", "all_group_chats", "all_chat_administrators",
|
||
|
|
]).optional(),
|
||
|
|
chat: chatField.optional(),
|
||
|
|
language_code: z.string().optional(),
|
||
|
|
},
|
||
|
|
handler: async ({ commands, scope, chat, language_code }, { tg }) => {
|
||
|
|
const p: Record<string, unknown> = { commands, language_code };
|
||
|
|
if (chat !== undefined) p.scope = { type: scope === "all_chat_administrators" ? "chat_administrators" : "chat", chat_id: chat };
|
||
|
|
else if (scope) p.scope = { type: scope };
|
||
|
|
return { updated: await tg.call("setMyCommands", p) };
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
];
|
||
|
|
|
||
|
|
// ---- JSON Schema conversion for Gemini function declarations ----
|
||
|
|
|
||
|
|
// Gemini's parameters block accepts a JSON-Schema-ish dialect. zod-to-json-schema
|
||
|
|
// emits JSON-Schema draft-07; the field shapes we use (string/integer/number/boolean/array/object/enum)
|
||
|
|
// pass through unchanged. We strip `$schema` since Gemini ignores it.
|
||
|
|
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 doesn't accept `union/anyOf` for chat fields — flatten to "string" for compatibility.
|
||
|
|
if (k === "anyOf" || k === "oneOf") {
|
||
|
|
// Pick the first non-null subtype heuristically.
|
||
|
|
const variants = (v as unknown[]).filter((x) => x && (x as Record<string, unknown>).type !== "null");
|
||
|
|
if (variants.length === 1) return cleanForGemini(variants[0]);
|
||
|
|
// For mixed types we fall back to string.
|
||
|
|
return { type: "string" };
|
||
|
|
}
|
||
|
|
out[k] = cleanForGemini(v);
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
return s;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function toolToGeminiFunction(t: Tool<z.ZodRawShape>) {
|
||
|
|
// Gemini's schema dialect rejects $ref/$defs/$schema/additionalProperties, so:
|
||
|
|
// - $refStrategy "none" inlines shared sub-schemas (e.g. ButtonSchema reused across tools).
|
||
|
|
// - cleanForGemini strips the rest.
|
||
|
|
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: {} },
|
||
|
|
};
|
||
|
|
}
|