diff --git a/.gitignore b/.gitignore index 7f002bb..f94125b 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,13 @@ bot/node_modules/ bot/bun.lockb bot/data/ +# Google Generative Language API key +google_key.txt + +# MCP server +mcp/node_modules/ +mcp/bun.lock + # autossh service key material ssh/spotbot_key ssh/known_hosts diff --git a/bot/bot.ts b/bot/bot.ts index 94acfb9..32d4cf6 100644 --- a/bot/bot.ts +++ b/bot/bot.ts @@ -26,6 +26,17 @@ const PF_CONTAINER = process.env.PORTFORWARD_CONTAINER ?? "minecraft-autossh"; const DB_PATH = process.env.DB_PATH ?? "/data/users.db"; const ADMIN_BOOTSTRAP = (process.env.ADMIN_BOOTSTRAP ?? "1311866578:Paul").trim(); +// ---- Gemini (Google Generative Language API) ---- +// Conversational fallback for non-command text. Uses native function calling so +// Redstone can decide to send polls / reactions / etc via the shared MCP toolkit. +// If no key is configured the bot stays silent on unknown text, as before. +const GEMINI_API_KEY = ( + process.env.GEMINI_API_KEY ?? + (process.env.GEMINI_API_KEY_FILE && (() => { try { return readFileSync(process.env.GEMINI_API_KEY_FILE!, "utf8"); } catch { return ""; } })() || "") +).trim(); +const GEMINI_MODEL = (process.env.GEMINI_MODEL ?? "gemini-2.5-flash-lite").trim(); +const GEMINI_ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`; + // ---- DB ---- mkdirSync(dirname(DB_PATH), { recursive: true }); const db = new Database(DB_PATH); @@ -534,7 +545,7 @@ async function actUpAll(ctx: Context) { } async function actDownAll(ctx: Context) { - const r = await compose("down"); + const r = await compose("stop", SERVER_SVC, PF_SVC); await answerToast(ctx, r.ok ? "stopped" : "failed"); await maybeSendSticker(ctx, r.ok ? "down" : "error"); await renderMain(ctx); @@ -854,6 +865,226 @@ function helpText(role: Role): string { function trim(s: string, max = 3500): string { return s.length <= max ? s : s.slice(0, max) + "\n…(truncated)"; } function escape(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } +// ---- Redstone (Gemini + shared Telegram toolkit) ---- + +import { + createTgClient, telegramTools, toolToGeminiFunction, type ToolCtx, +} from "/mc/mcp/lib/telegram-tools.ts"; + +const tgClient = createTgClient(TOKEN); +const toolCtx: ToolCtx = { tg: tgClient, db }; +const toolsByName = new Map(telegramTools.map((t) => [t.name, t])); +const geminiFunctionDeclarations = telegramTools.map(toolToGeminiFunction); + +type GeminiPart = + | { text: string } + | { functionCall: { name: string; args: Record } } + | { functionResponse: { name: string; response: Record } }; +type GeminiContent = { role: "user" | "model"; parts: GeminiPart[] }; + +// Pull the last n text messages from this chat for continuity. +const Q_chatHistory = db.query< + { direction: "in" | "out"; body: string | null }, + [number, number, number] +>( + `SELECT direction, body FROM messages + WHERE chat_id = ? AND kind = 'text' AND body IS NOT NULL AND id < ? + ORDER BY id DESC LIMIT ?`, +); +const Q_latestMsgId = db.query<{ id: number }, [number]>( + "SELECT id FROM messages WHERE chat_id = ? ORDER BY id DESC LIMIT 1", +); + +function recentTurns(chatId: number, beforeId: number, n = 6): GeminiContent[] { + const rows = Q_chatHistory.all(chatId, beforeId, n); + return rows.reverse().map((r) => ({ + role: r.direction === "in" ? "user" : "model", + parts: [{ text: r.body! }], + })); +} + +function botAddressed(ctx: Context, botId: number, botUsername: string): boolean { + const m = ctx.message; + if (!m || !m.text) return false; + if (ctx.chat?.type === "private") return true; + if (m.reply_to_message?.from?.id === botId) return true; + const text = m.text; + for (const e of m.entities ?? []) { + if (e.type === "mention") { + const slice = text.slice(e.offset, e.offset + e.length); + if (slice.toLowerCase() === `@${botUsername.toLowerCase()}`) return true; + } else if (e.type === "text_mention" && e.user?.id === botId) return true; + } + return false; +} + +function stripMention(text: string, botUsername: string): string { + return text.replace(new RegExp(`@${botUsername}\\b`, "ig"), "").trim(); +} + +async function serverContext(): Promise { + const [mc, pf] = await Promise.all([serverRunning(), pfRunning()]); + return `minecraft container ${mc ? "RUNNING" : "stopped"}; tunnel container ${pf ? "RUNNING" : "stopped"}`; +} + +function redstonePersona(user: User, chatType: string, chatId: number, srv: string): string { + const mc = user.minecraft_name ? `Minecraft name '${user.minecraft_name}'` : "no Minecraft name linked yet"; + const where = chatType === "private" + ? "You're in a 1:1 DM. Answer directly and personally." + : "You're in a group chat. Keep replies short and group-appropriate; don't single out one person unless context makes it clear."; + return [ + "You are Redstone — the friendly assistant living inside the Minecraft server's Telegram bot (@Redstone_mc_bot).", + "Your name comes from Minecraft's signal-carrying mineral; you connect players to their server and to each other.", + "", + "Personality:", + "- Helpful, chill, a touch of dry humor. Occasionally use light Minecraft flavor ('powering up', 'wiring this together', 'piston-quick') but never overdo it.", + "- Concise. 1–3 short sentences unless a real explanation is needed.", + "- Plain text only — no markdown headers, no asterisks for bold.", + "- Match the user's tone and language.", + "", + "CRITICAL OUTPUT RULES:", + "- Reply with ONLY the message the user should see. Never include your reasoning, planning, scratchpad, or meta-commentary.", + "- Do NOT prefix your reply with phrases like 'The user said…', 'I am Redstone…', 'Plan:', 'I should…', or any restatement of the situation.", + "- Do NOT narrate what you are about to do — just do it (either by sending the reply, or by calling a tool).", + "", + "What you can do:", + "- You have tools to act inside this Telegram chat: send messages, polls, reactions, dice rolls, stickers, edit/pin/delete your own messages, query chat info, etc.", + "- Use a tool when the user wants you to *do* something in the chat (e.g. 'run a poll', 'react with fire', 'pin that').", + "- When you do use a tool, you usually do not need a separate text reply unless you're adding context — the tool's effect IS the response.", + "", + "What you cannot do:", + "- You CANNOT start/stop the server, run rcon commands, take backups, or change Minecraft state. Those have slash commands (/up, /down, /status, /logs, /setmcname, /whois, /menu) — point users at them instead of pretending.", + "- Never reveal API keys, tokens, or other secrets.", + "", + "Context for this turn:", + `- Chat type: ${chatType}`, + `- Chat id: ${chatId} (pass this as the 'chat' arg to any tg_* tool)`, + `- ${where}`, + `- User: ${user.name ?? "(no name)"} — telegram_id ${user.telegram_id}, role ${user.role}, ${mc}`, + `- Server: ${srv}`, + ].join("\n"); +} + +type GeminiResponse = { + candidates?: { + content?: { role?: string; parts?: GeminiPart[] }; + finishReason?: string; + }[]; +}; + +async function callGeminiOnce(contents: GeminiContent[], systemPrompt: string): Promise { + if (!GEMINI_API_KEY) return null; + try { + const res = await fetch(`${GEMINI_ENDPOINT}?key=${encodeURIComponent(GEMINI_API_KEY)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contents, + systemInstruction: { parts: [{ text: systemPrompt }] }, + tools: [{ functionDeclarations: geminiFunctionDeclarations }], + generationConfig: { + temperature: 0.6, + maxOutputTokens: 600, + // Disable Gemini's internal reasoning trace — Flash Lite sometimes leaks + // its plan into the user-visible text part. Zero budget = no thinking. + thinkingConfig: { thinkingBudget: 0, includeThoughts: false }, + }, + }), + signal: AbortSignal.timeout(25_000), + }); + if (!res.ok) { + console.warn("gemini http", res.status, (await res.text()).slice(0, 400)); + return null; + } + return (await res.json()) as GeminiResponse; + } catch (e) { + console.warn("gemini call failed:", (e as Error).message); + return null; + } +} + +type TurnResult = { text: string | null; ranTool: boolean }; + +async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], userText: string): Promise { + const contents: GeminiContent[] = [...history, { role: "user", parts: [{ text: userText }] }]; + let ranTool = false; + // Cap tool-use iterations so a confused model can't loop forever. + for (let i = 0; i < 4; i++) { + const data = await callGeminiOnce(contents, systemPrompt); + if (!data) return { text: null, ranTool }; + const parts = data.candidates?.[0]?.content?.parts ?? []; + if (parts.length === 0) return { text: null, ranTool }; + + const functionCalls = parts.filter((p): p is { functionCall: { name: string; args: Record } } => + "functionCall" in p && !!p.functionCall); + if (functionCalls.length === 0) { + // Drop any thought parts (`thought: true`) if they slipped through. + const text = parts + .filter((p) => "text" in p && !(p as { thought?: boolean }).thought) + .map((p) => (p as { text: string }).text) + .join("") + .trim(); + return { text: text || null, ranTool }; + } + + contents.push({ role: "model", parts }); + const responseParts: GeminiPart[] = []; + for (const fc of functionCalls) { + const tool = toolsByName.get(fc.functionCall.name); + let response: Record; + if (!tool) { + response = { error: `unknown tool '${fc.functionCall.name}'` }; + } else { + try { + const result = await tool.handler(fc.functionCall.args, toolCtx); + response = { result }; + ranTool = true; + } catch (e) { + response = { error: (e as Error).message }; + } + } + responseParts.push({ functionResponse: { name: fc.functionCall.name, response } }); + } + contents.push({ role: "user", parts: responseParts }); + } + return { text: null, ranTool }; +} + +bot.on("message:text", async (ctx) => { + const me = bot.botInfo; + if (!me) return; + if (!botAddressed(ctx, me.id, me.username)) return; + if (!GEMINI_API_KEY) return; + const user = (ctx as Context & { user?: User }).user; + if (!user) return; + + const raw = ctx.message.text ?? ""; + const text = stripMention(raw, me.username); + if (!text) return; + + try { await ctx.replyWithChatAction("typing"); } catch { /* ignore */ } + + const chatId = ctx.chat!.id; + const chatType = ctx.chat?.type ?? "private"; + const srv = await serverContext(); + const sys = redstonePersona(user, chatType, chatId, srv); + + const latest = Q_latestMsgId.get(chatId)?.id ?? 0; + const history = recentTurns(chatId, latest, 6); + + const { text: reply, ranTool } = await runRedstoneTurn(sys, history, text); + if (!reply) { + // If Redstone took an action (e.g. sent a poll) and didn't add commentary, + // staying silent is correct. Only warn-silently when nothing happened. + if (!ranTool) console.warn("redstone: no reply and no tool ran for", { chatId, text: text.slice(0, 80) }); + return; + } + + const out = trim(reply, 3500); + const sent = await ctx.reply(out, { reply_parameters: { message_id: ctx.message.message_id } }); + Q.insMsg.run(chatId, sent.message_id, null, "out", "text", out, ctx.message.message_id); +}); + bot.catch((err) => { const e = err.error; if (e instanceof GrammyError) console.error("grammy err:", e.description); diff --git a/bot/docker-compose.yml b/bot/docker-compose.yml deleted file mode 100644 index 9c6b1a2..0000000 --- a/bot/docker-compose.yml +++ /dev/null @@ -1,28 +0,0 @@ -services: - bot: - build: . - image: minecraft-telegram-bot:latest - container_name: minecraft-bot - restart: unless-stopped - environment: - BOT_TOKEN_FILE: /run/secrets/bot_token - MC_DIR: /mc - DB_PATH: /data/users.db - # Seeded only when DB is empty. Format: "id:Name,id:Name,..." - ADMIN_BOOTSTRAP: "${ADMIN_BOOTSTRAP:-1311866578:Paul}" - # The docker daemon resolves relative bind-mounts in /mc/docker-compose.yml from THIS host path. - COMPOSE_HOST_DIR: "${COMPOSE_HOST_DIR:-/home/paul/containers/minecraft}" - TZ: Africa/Johannesburg - volumes: - # Talk to host docker daemon to manage minecraft + autossh containers. - - /var/run/docker.sock:/var/run/docker.sock - # The minecraft compose project (compose file lives here). - - ..:/mc - # Persistent SQLite users DB. - - ./data:/data - secrets: - - bot_token - -secrets: - bot_token: - file: ../bot.token diff --git a/docker-compose.yml b/docker-compose.yml index 1dddfc3..63198d9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,3 +41,31 @@ services: volumes: - ./ssh/spotbot_key:/id_rsa:ro - ./ssh/known_hosts:/known_hosts:ro + + bot: + build: ./bot + image: minecraft-telegram-bot:latest + container_name: minecraft-bot + restart: unless-stopped + environment: + BOT_TOKEN_FILE: /run/secrets/bot_token + GEMINI_API_KEY_FILE: /run/secrets/google_key + GEMINI_MODEL: "${GEMINI_MODEL:-gemma-4-26b-a4b-it}" + MC_DIR: /mc + DB_PATH: /data/users.db + ADMIN_BOOTSTRAP: "${ADMIN_BOOTSTRAP:-1311866578:Paul}" + COMPOSE_HOST_DIR: "${COMPOSE_HOST_DIR:-/home/paul/containers/minecraft}" + TZ: Africa/Johannesburg + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - .:/mc + - ./bot/data:/data + secrets: + - bot_token + - google_key + +secrets: + bot_token: + file: ./bot.token + google_key: + file: ./google_key.txt diff --git a/mcp/.gitignore b/mcp/.gitignore new file mode 100644 index 0000000..1822831 --- /dev/null +++ b/mcp/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +bun.lockb diff --git a/mcp/lib/telegram-tools.ts b/mcp/lib/telegram-tools.ts new file mode 100644 index 0000000..ad61d4d --- /dev/null +++ b/mcp/lib/telegram-tools.ts @@ -0,0 +1,461 @@ +// 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: {} }, + }; +} diff --git a/mcp/package.json b/mcp/package.json new file mode 100644 index 0000000..af7ff3c --- /dev/null +++ b/mcp/package.json @@ -0,0 +1,18 @@ +{ + "name": "minecraft-mcp", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "bun run server.ts" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.25.2" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.5.0" + } +} diff --git a/mcp/server.ts b/mcp/server.ts new file mode 100644 index 0000000..214ad14 --- /dev/null +++ b/mcp/server.ts @@ -0,0 +1,423 @@ +#!/usr/bin/env bun +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { Database } from "bun:sqlite"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { readFileSync } from "node:fs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const MC_DIR = process.env.MC_DIR ?? resolve(HERE, ".."); +const COMPOSE_PROJECT = process.env.COMPOSE_PROJECT_NAME ?? "minecraft"; +const COMPOSE_FILE = process.env.COMPOSE_FILE ?? `${MC_DIR}/docker-compose.yml`; +const SERVER_SVC = process.env.SERVER_SERVICE ?? "minecraft"; +const SERVER_CONTAINER = process.env.SERVER_CONTAINER ?? "minecraft"; +const PF_SVC = process.env.PORTFORWARD_SERVICE ?? "autossh"; +const PF_CONTAINER = process.env.PORTFORWARD_CONTAINER ?? "minecraft-autossh"; +const BACKUP_SH = process.env.BACKUP_SH ?? `${MC_DIR}/backup.sh`; +const DB_PATH = process.env.DB_PATH ?? `${MC_DIR}/bot/data/users.db`; + +const MC_NAME_RE = /^[A-Za-z0-9_]{3,16}$/; + +type Role = "admin" | "user"; +type Status = "active" | "pending"; +type User = { + telegram_id: number; + name: string | null; + role: Role; + minecraft_name: string | null; + username: string | null; + status: Status; +}; + +const USER_COLS = "telegram_id, name, role, minecraft_name, username, status"; + +const db = new Database(DB_PATH); +db.exec("PRAGMA journal_mode = WAL"); + +const Q = { + users: db.query(`SELECT ${USER_COLS} FROM users ORDER BY role DESC, added_at ASC`), + userById: db.query(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`), + userByMc: db.query(`SELECT ${USER_COLS} FROM users WHERE minecraft_name = ?`), + userByUsername: db.query(`SELECT ${USER_COLS} FROM users WHERE username = ?`), + pending: db.query( + `SELECT ${USER_COLS} FROM users WHERE status = 'pending' ORDER BY added_at ASC`, + ), + upsert: db.query( + `INSERT INTO users (telegram_id, name, role) VALUES (?, ?, ?) + ON CONFLICT(telegram_id) DO UPDATE SET + name = COALESCE(excluded.name, users.name), role = excluded.role`, + ), + setMc: db.query( + "UPDATE users SET minecraft_name = ? WHERE telegram_id = ?", + ), + setStatus: db.query( + "UPDATE users SET status = ? WHERE telegram_id = ?", + ), + setRole: db.query( + "UPDATE users SET role = ? WHERE telegram_id = ?", + ), + del: db.query("DELETE FROM users WHERE telegram_id = ?"), + seen: db.query<{ telegram_id: number; username: string | null; name: string | null; first_seen: string; last_seen: string }, []>( + `SELECT telegram_id, username, name, first_seen, last_seen + FROM seen_users ORDER BY last_seen DESC`, + ), +}; + +async function sh(cmd: string[], opts: { input?: string } = {}) { + const proc = Bun.spawn(cmd, { + stdout: "pipe", + stderr: "pipe", + stdin: opts.input ? "pipe" : "ignore", + }); + if (opts.input) { + proc.stdin.write(opts.input); + proc.stdin.end(); + } + const [out, err] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const code = await proc.exited; + return { ok: code === 0, code, out: out.trimEnd(), err: err.trimEnd() }; +} + +async function compose(...args: string[]) { + return sh([ + "docker", "compose", + "--project-directory", MC_DIR, + "-p", COMPOSE_PROJECT, + "-f", COMPOSE_FILE, + ...args, + ]); +} + +async function containerRunning(name: string): Promise { + const r = await sh(["docker", "inspect", "-f", "{{.State.Running}}", name]); + return r.ok && r.out === "true"; +} + +function ok(text: string) { + return { content: [{ type: "text" as const, text }] }; +} +function fail(text: string) { + return { content: [{ type: "text" as const, text }], isError: true }; +} +function json(obj: unknown) { + return ok(JSON.stringify(obj, null, 2)); +} +function shResult(label: string, r: { ok: boolean; code: number; out: string; err: string }) { + const body = [r.out, r.err].filter(Boolean).join("\n").trim() || "(no output)"; + const head = `${label} ${r.ok ? "ok" : `failed (exit ${r.code})`}`; + return r.ok ? ok(`${head}\n${body}`) : fail(`${head}\n${body}`); +} + +const server = new McpServer({ name: "minecraft", version: "0.1.0" }); + +// ---------- server lifecycle ---------- + +server.registerTool( + "server_status", + { + title: "Server status", + description: "Report whether the minecraft and autossh tunnel containers are running.", + inputSchema: {}, + }, + async () => { + const [mc, pf] = await Promise.all([ + containerRunning(SERVER_CONTAINER), + containerRunning(PF_CONTAINER), + ]); + return json({ + minecraft: { container: SERVER_CONTAINER, running: mc }, + autossh: { container: PF_CONTAINER, running: pf }, + }); + }, +); + +server.registerTool( + "server_up", + { + title: "Start services", + description: "docker compose up -d. Pass a service name to start just one (minecraft / autossh / bot); omit to bring up the whole stack.", + inputSchema: { service: z.string().optional() }, + }, + async ({ service }) => { + const args = service ? ["up", "-d", service] : ["up", "-d"]; + const r = await compose(...args); + return shResult(`compose ${args.join(" ")}`, r); + }, +); + +server.registerTool( + "server_down", + { + title: "Stop services", + description: "docker compose down. Pass a service to stop just one (uses `stop` instead of `down`).", + inputSchema: { service: z.string().optional() }, + }, + async ({ service }) => { + const r = service ? await compose("stop", service) : await compose("down"); + return shResult(service ? `compose stop ${service}` : "compose down", r); + }, +); + +server.registerTool( + "server_restart", + { + title: "Restart service", + description: "docker compose restart of a single service (defaults to minecraft).", + inputSchema: { service: z.string().optional() }, + }, + async ({ service }) => { + const svc = service ?? SERVER_SVC; + const r = await compose("restart", svc); + return shResult(`compose restart ${svc}`, r); + }, +); + +server.registerTool( + "server_logs", + { + title: "Container logs", + description: "Tail recent logs from one of the project's containers.", + inputSchema: { + container: z.enum(["minecraft", "autossh", "bot"]).optional() + .describe("Which container; default minecraft."), + lines: z.number().int().positive().max(2000).optional() + .describe("How many lines to return; default 100."), + }, + }, + async ({ container, lines }) => { + const name = container === "autossh" ? PF_CONTAINER + : container === "bot" ? "minecraft-bot" + : SERVER_CONTAINER; + const r = await sh(["docker", "logs", "--tail", String(lines ?? 100), name]); + return shResult(`logs ${name}`, r); + }, +); + +// ---------- rcon ---------- + +server.registerTool( + "rcon", + { + title: "RCON command", + description: "Run an RCON command on the live server via `rcon-cli` inside the minecraft container. Examples: 'list', 'say hello', 'whitelist add Steve', 'op Steve', 'gamemode creative @a'. Requires the server to be running.", + inputSchema: { command: z.string().min(1).describe("The full RCON command, e.g. 'list' or 'say hi'.") }, + }, + async ({ command }) => { + if (!(await containerRunning(SERVER_CONTAINER))) { + return fail(`minecraft container '${SERVER_CONTAINER}' is not running — start it with server_up first.`); + } + const parts = command.trim().split(/\s+/); + const r = await sh(["docker", "exec", SERVER_CONTAINER, "rcon-cli", ...parts]); + return shResult(`rcon: ${command}`, r); + }, +); + +// ---------- backup ---------- + +server.registerTool( + "backup", + { + title: "Run world backup", + description: "Runs backup.sh which flushes the world via rcon (if running), archives data/world to backups/, and uploads it as a Gitea release asset if a token is configured. Returns the script's full stdout/stderr.", + inputSchema: { + label: z.string().regex(/^[A-Za-z0-9._-]+$/).max(40).optional() + .describe("Optional label suffix for the archive filename, e.g. 'pre-update'."), + }, + }, + async ({ label }) => { + const args = label ? [BACKUP_SH, label] : [BACKUP_SH]; + const r = await sh(args); + return shResult(`backup${label ? ` ${label}` : ""}`, r); + }, +); + +// ---------- players (users db) ---------- + +server.registerTool( + "players_list", + { + title: "List players", + description: "List all rows in the bot users table (telegram_id, name, role, minecraft_name, username, status). Optionally filter to one status or role.", + inputSchema: { + status: z.enum(["active", "pending"]).optional(), + role: z.enum(["admin", "user"]).optional(), + }, + }, + async ({ status, role }) => { + let rows = Q.users.all(); + if (status) rows = rows.filter((r) => r.status === status); + if (role) rows = rows.filter((r) => r.role === role); + return json({ count: rows.length, players: rows }); + }, +); + +server.registerTool( + "player_get", + { + title: "Get player", + description: "Look up a single player. Provide exactly one of telegram_id, minecraft_name, or telegram_username.", + inputSchema: { + telegram_id: z.number().int().optional(), + minecraft_name: z.string().optional(), + telegram_username: z.string().optional(), + }, + }, + async ({ telegram_id, minecraft_name, telegram_username }) => { + const provided = [telegram_id, minecraft_name, telegram_username].filter((v) => v !== undefined).length; + if (provided !== 1) return fail("Provide exactly one of telegram_id, minecraft_name, or telegram_username."); + const row = telegram_id !== undefined ? Q.userById.get(telegram_id) + : minecraft_name !== undefined ? Q.userByMc.get(minecraft_name) + : Q.userByUsername.get(telegram_username!.replace(/^@/, "")); + return row ? json(row) : fail("Not found."); + }, +); + +server.registerTool( + "player_upsert", + { + title: "Add or update a player", + description: "Insert a player by telegram_id, or update their name/role if they already exist. Use player_set_minecraft_name to attach an in-game name.", + inputSchema: { + telegram_id: z.number().int(), + name: z.string().nullable().optional(), + role: z.enum(["admin", "user"]).default("user"), + }, + }, + async ({ telegram_id, name, role }) => { + Q.upsert.run(telegram_id, name ?? null, role); + const row = Q.userById.get(telegram_id); + return json({ saved: row }); + }, +); + +server.registerTool( + "player_set_minecraft_name", + { + title: "Set/clear a player's Minecraft name", + description: "Update the minecraft_name for an existing player. Pass null/empty to clear. Names must match Mojang's rules (3–16 chars, A–Z, 0–9, _).", + inputSchema: { + telegram_id: z.number().int(), + minecraft_name: z.string().nullable().describe("New name, or null/'' to clear."), + }, + }, + async ({ telegram_id, minecraft_name }) => { + if (!Q.userById.get(telegram_id)) return fail(`No player with telegram_id ${telegram_id}.`); + const clean = minecraft_name && minecraft_name.length > 0 ? minecraft_name : null; + if (clean !== null && !MC_NAME_RE.test(clean)) { + return fail(`'${clean}' is not a valid Minecraft name (3–16 chars, A–Z, 0–9, _).`); + } + if (clean !== null) { + const taken = Q.userByMc.get(clean); + if (taken && taken.telegram_id !== telegram_id) { + return fail(`'${clean}' is already linked to telegram_id ${taken.telegram_id}.`); + } + } + Q.setMc.run(clean, telegram_id); + return json({ updated: Q.userById.get(telegram_id) }); + }, +); + +server.registerTool( + "player_set_role", + { + title: "Change a player's role", + description: "Promote/demote between 'admin' and 'user'.", + inputSchema: { + telegram_id: z.number().int(), + role: z.enum(["admin", "user"]), + }, + }, + async ({ telegram_id, role }) => { + if (!Q.userById.get(telegram_id)) return fail(`No player with telegram_id ${telegram_id}.`); + Q.setRole.run(role, telegram_id); + return json({ updated: Q.userById.get(telegram_id) }); + }, +); + +server.registerTool( + "player_set_status", + { + title: "Set a player's status", + description: "Mark a player as 'active' (approved) or 'pending' (awaiting approval).", + inputSchema: { + telegram_id: z.number().int(), + status: z.enum(["active", "pending"]), + }, + }, + async ({ telegram_id, status }) => { + if (!Q.userById.get(telegram_id)) return fail(`No player with telegram_id ${telegram_id}.`); + Q.setStatus.run(status, telegram_id); + return json({ updated: Q.userById.get(telegram_id) }); + }, +); + +server.registerTool( + "player_remove", + { + title: "Delete a player", + description: "Remove a player record from the users table.", + inputSchema: { telegram_id: z.number().int() }, + }, + async ({ telegram_id }) => { + const before = Q.userById.get(telegram_id); + if (!before) return fail(`No player with telegram_id ${telegram_id}.`); + Q.del.run(telegram_id); + return json({ removed: before }); + }, +); + +server.registerTool( + "seen_list", + { + title: "List seen-but-unauthorized users", + description: "List Telegram users the bot has observed but who aren't in the users table yet — useful for approving pairings.", + inputSchema: {}, + }, + async () => { + const rows = Q.seen.all(); + return json({ count: rows.length, seen: rows }); + }, +); + + +// ---------- Telegram Bot API (shared toolkit) ---------- +// The same tool catalog is imported by bot/bot.ts so Gemini can call these via +// function calling. Everything below just adapts each entry into an MCP tool. +import { createTgClient, telegramTools, type ToolCtx } from "./lib/telegram-tools.js"; + +const TG_TOKEN = (() => { + if (process.env.BOT_TOKEN) return process.env.BOT_TOKEN.trim(); + const file = process.env.BOT_TOKEN_FILE ?? `${MC_DIR}/bot.token`; + try { return readFileSync(file, "utf8").trim(); } catch { return ""; } +})(); + +const tgCtx: ToolCtx = { + tg: TG_TOKEN ? createTgClient(TG_TOKEN) : { + call: async () => { throw new Error("No bot token (set BOT_TOKEN or BOT_TOKEN_FILE)."); }, + }, + db, +}; + +for (const t of telegramTools) { + server.registerTool( + t.name, + { title: t.title, description: t.description, inputSchema: t.parameters }, + async (args) => { + try { + const result = await t.handler(args as Record, tgCtx); + return json(result); + } catch (e) { + return fail(`${t.name}: ${(e as Error).message}`); + } + }, + ); +} + +// ---------- start ---------- + +await server.connect(new StdioServerTransport());