Files
Minecraft-Server/mcp/lib/telegram-tools.ts
Paul Kloppers 875d6fd6dd add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.

mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.

mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.

bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.

docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00

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