Files
Minecraft-Server/mcp/lib/telegram-tools.ts

462 lines
19 KiB
TypeScript
Raw Normal View History

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