// 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: (method: string, params?: Record) => Promise; }; 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; }, }; } // ---- Tool context passed to every handler ---- export type ToolCtx = { tg: TgClient; db: Database; }; // ---- Tool definition shape ---- export type Tool = { name: string; title: string; description: string; parameters: Params; handler: (args: z.infer>, ctx: ToolCtx) => Promise; }; // ---- 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[][] | undefined) { if (!rows || rows.length === 0) return undefined; return { inline_keyboard: rows.map((row) => row.map((b) => { const out: Record = { 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

(t: Tool

): Tool { return t as Tool; } export const telegramTools: Tool[] = [ // ---- 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 = { 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 = { 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 = { 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 = { 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 = { 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 = {}; for (const [k, v] of Object.entries(s as Record)) { 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).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) { // 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; return { name: t.name, description: t.description, parameters: params.type ? params : { type: "object", properties: {} }, }; }