Files
Minecraft-Server/mcp/lib/telegram-tools.ts
Paul Kloppers d22cb73a2c extract Minecraft tools to shared toolkit; gate Redstone replies to authorized senders
Redstone previously only saw the 23 tg_* tools — it had no idea the
Minecraft stack existed, so questions like "is the server up?" went
unanswered. This change extracts the 15 Minecraft tools (lifecycle,
rcon, backup, players, seen) into the same shared catalog the Telegram
tools already used, so both Gemini and external MCP clients see them.

mcp/lib/types.ts (new) holds the shared shape: Tool<P>, ToolCtx,
createTgClient, createMcRuntime (a Bun.spawn-based wrapper for docker
compose / docker exec), and toolToGeminiFunction (zod → Gemini schema,
now also stripping exclusiveMinimum/Maximum since Gemini rejects them).

mcp/lib/minecraft-tools.ts (new) is the catalog itself. Eight handlers
are flagged requiresAdmin: rcon, backup, and all destructive player_*
writes plus seen_list. mcp/server.ts trusts the caller (Claude / Paul
on the host) and ignores the flag; bot/bot.ts honours it at dispatch
time, returning {error: "...requires admin role..."} to Gemini so it
can explain to the user instead of attempting the call.

mcp/server.ts shrinks from 423 lines to 70 — a single loop over both
catalogs replaces the hand-rolled registrations.

bot/bot.ts wires both catalogs into the function declarations and adds
the admin gate. It also gains a defensive re-check on every incoming
group/DM text: the Redstone handler now does its own lookup of
ctx.from.id against the users table and refuses to reply unless
status='active'. This is belt-and-braces — the auth middleware already
short-circuits unauthorized callers earlier in the chain, but with
privacy-mode-off groups now feeding every message through, a future
refactor that reorders middleware shouldn't be able to make Redstone
respond to strangers.

Drops thinkingConfig from the Gemini call (gemini-2.5-flash-lite
rejects it outright with HTTP 400).

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

371 lines
15 KiB
TypeScript

// Telegram tool catalog. Shared types + helpers live in ./types.ts.
import { z } from "zod";
import { tool, type Tool } from "./types.ts";
export { createTgClient, toolToGeminiFunction, type ToolCtx, type Tool, type TgClient } from "./types.ts";
// ---- 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 ----
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) };
},
}),
];