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

371 lines
15 KiB
TypeScript
Raw Normal View History

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