// Minecraft tool catalog (server lifecycle, rcon, backup, player DB). // Imported by mcp/server.ts and bot/bot.ts via the shared toolkit. import { z } from "zod"; import { tool, type Tool } from "./types.ts"; const MC_NAME_RE = /^[A-Za-z0-9_]{3,16}$/; type Role = "admin" | "user"; type Status = "active" | "pending"; type DbUser = { 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"; export const minecraftTools: Tool[] = [ // ---- server lifecycle ---- tool({ name: "server_status", title: "Server status", description: "Report whether the minecraft and autossh tunnel containers are running.", parameters: {}, handler: async (_a, { mc }) => { const [server, tunnel] = await Promise.all([ mc.containerRunning(mc.config.serverContainer), mc.containerRunning(mc.config.pfContainer), ]); return { minecraft: { container: mc.config.serverContainer, running: server }, autossh: { container: mc.config.pfContainer, running: tunnel }, }; }, }), tool({ name: "server_up", title: "Start services", description: "docker compose up -d. Pass a service name (minecraft / autossh / bot) to start just one; omit for the whole stack.", parameters: { service: z.string().optional() }, handler: async ({ service }, { mc }) => { const r = await (service ? mc.compose("up", "-d", service) : mc.compose("up", "-d")); return { ok: r.ok, code: r.code, output: [r.out, r.err].filter(Boolean).join("\n").trim() || "(no output)" }; }, }), tool({ name: "server_down", title: "Stop services", description: "Stop the minecraft + autossh services. The bot container is left running on purpose — a full `compose down` would shut the bot off mid-conversation. Pass a specific service to stop just that one.", parameters: { service: z.string().optional() }, handler: async ({ service }, { mc }) => { // Without a service arg, stop only minecraft + autossh and leave the bot // running. `compose down` would tear down everything including the bot, // which kills the very process executing this tool call. const r = service ? await mc.compose("stop", service) : await mc.compose("stop", mc.config.serverSvc, mc.config.pfSvc); return { ok: r.ok, code: r.code, output: [r.out, r.err].filter(Boolean).join("\n").trim() || "(no output)" }; }, }), tool({ name: "server_restart", title: "Restart a service", description: "docker compose restart of a single service (defaults to minecraft).", parameters: { service: z.string().optional() }, handler: async ({ service }, { mc }) => { const svc = service ?? mc.config.serverSvc; const r = await mc.compose("restart", svc); return { ok: r.ok, code: r.code, output: [r.out, r.err].filter(Boolean).join("\n").trim() || "(no output)" }; }, }), tool({ name: "server_logs", title: "Container logs", description: "Tail recent logs from one of the project's containers.", parameters: { 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."), }, handler: async ({ container, lines }, { mc }) => { const name = container === "autossh" ? mc.config.pfContainer : container === "bot" ? "minecraft-bot" : mc.config.serverContainer; const r = await mc.sh(["docker", "logs", "--tail", String(lines ?? 100), name]); return { ok: r.ok, output: [r.out, r.err].filter(Boolean).join("\n").trim() || "(no output)" }; }, }), // ---- rcon (admin-only) ---- tool({ name: "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'. Server must be running.", parameters: { command: z.string().min(1).describe("Full RCON command, e.g. 'list' or 'say hi'.") }, requiresAdmin: true, handler: async ({ command }, { mc }) => { if (!(await mc.containerRunning(mc.config.serverContainer))) { throw new Error(`minecraft container '${mc.config.serverContainer}' is not running — start it with server_up first.`); } const parts = command.trim().split(/\s+/); const r = await mc.sh(["docker", "exec", mc.config.serverContainer, "rcon-cli", ...parts]); if (!r.ok) throw new Error(`rcon failed (exit ${r.code}): ${[r.out, r.err].filter(Boolean).join(" ")}`); return { command, output: r.out || "(no output)" }; }, }), // ---- backup (admin-only) ---- tool({ name: "backup", title: "Run world backup", description: "Runs backup.sh: flushes the world via rcon if the server is running, archives data/world to backups/, and uploads it as a Gitea release asset if a token is configured. May take a while for large worlds.", parameters: { label: z.string().regex(/^[A-Za-z0-9._-]+$/).max(40).optional() .describe("Optional label suffix for the archive filename, e.g. 'pre-update'."), }, requiresAdmin: true, handler: async ({ label }, { mc }) => { const args = label ? [mc.config.backupSh, label] : [mc.config.backupSh]; const r = await mc.sh(args); return { ok: r.ok, code: r.code, output: [r.out, r.err].filter(Boolean).join("\n").trim() || "(no output)", }; }, }), // ---- players (users db) ---- tool({ name: "players_list", title: "List players", description: "List all rows in the bot users table (telegram_id, name, role, minecraft_name, username, status). Optional status / role filter.", parameters: { status: z.enum(["active", "pending"]).optional(), role: z.enum(["admin", "user"]).optional(), }, handler: async ({ status, role }, { db }) => { let rows = db.prepare(`SELECT ${USER_COLS} FROM users ORDER BY role DESC, added_at ASC`).all() as DbUser[]; if (status) rows = rows.filter((r) => r.status === status); if (role) rows = rows.filter((r) => r.role === role); return { count: rows.length, players: rows }; }, }), tool({ name: "player_get", title: "Get player", description: "Look up a single player. Provide exactly one of telegram_id, minecraft_name, or 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 provided = [telegram_id, minecraft_name, telegram_username].filter((v) => v !== undefined).length; if (provided !== 1) throw new Error("Provide exactly one of telegram_id, minecraft_name, telegram_username."); const sql = `SELECT ${USER_COLS} 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 DbUser | undefined; if (!row) throw new Error("Not found."); return row; }, }), tool({ name: "player_upsert", title: "Add or update a player", description: "Insert a player by telegram_id, or update their name/role if they exist. Use player_set_minecraft_name to attach an in-game name.", parameters: { telegram_id: z.number().int(), name: z.string().nullable().optional(), role: z.enum(["admin", "user"]).default("user"), }, requiresAdmin: true, handler: async ({ telegram_id, name, role }, { db }) => { db.prepare( `INSERT INTO users (telegram_id, name, role) VALUES (?, ?, ?) ON CONFLICT(telegram_id) DO UPDATE SET name = COALESCE(excluded.name, users.name), role = excluded.role`, ).run(telegram_id, name ?? null, role); return { saved: db.prepare(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`).get(telegram_id) }; }, }), tool({ name: "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, _).", parameters: { telegram_id: z.number().int(), minecraft_name: z.string().nullable().describe("New name, or null/'' to clear."), }, requiresAdmin: true, handler: async ({ telegram_id, minecraft_name }, { db }) => { const existing = db.prepare(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`).get(telegram_id) as DbUser | undefined; if (!existing) throw new Error(`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)) throw new Error(`'${clean}' is not a valid Minecraft name (3–16 chars, A–Z, 0–9, _).`); if (clean !== null) { const taken = db.prepare(`SELECT telegram_id FROM users WHERE minecraft_name = ?`).get(clean) as { telegram_id: number } | undefined; if (taken && taken.telegram_id !== telegram_id) throw new Error(`'${clean}' is already linked to telegram_id ${taken.telegram_id}.`); } db.prepare(`UPDATE users SET minecraft_name = ? WHERE telegram_id = ?`).run(clean, telegram_id); return { updated: db.prepare(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`).get(telegram_id) }; }, }), tool({ name: "player_set_role", title: "Change a player's role", description: "Promote/demote between 'admin' and 'user'.", parameters: { telegram_id: z.number().int(), role: z.enum(["admin", "user"]), }, requiresAdmin: true, handler: async ({ telegram_id, role }, { db }) => { const existing = db.prepare(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`).get(telegram_id); if (!existing) throw new Error(`No player with telegram_id ${telegram_id}.`); db.prepare(`UPDATE users SET role = ? WHERE telegram_id = ?`).run(role, telegram_id); return { updated: db.prepare(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`).get(telegram_id) }; }, }), tool({ name: "player_set_status", title: "Set a player's status", description: "Mark a player as 'active' (approved) or 'pending' (awaiting approval).", parameters: { telegram_id: z.number().int(), status: z.enum(["active", "pending"]), }, requiresAdmin: true, handler: async ({ telegram_id, status }, { db }) => { const existing = db.prepare(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`).get(telegram_id); if (!existing) throw new Error(`No player with telegram_id ${telegram_id}.`); db.prepare(`UPDATE users SET status = ? WHERE telegram_id = ?`).run(status, telegram_id); return { updated: db.prepare(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`).get(telegram_id) }; }, }), tool({ name: "player_remove", title: "Delete a player", description: "Remove a player record from the users table.", parameters: { telegram_id: z.number().int() }, requiresAdmin: true, handler: async ({ telegram_id }, { db }) => { const before = db.prepare(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`).get(telegram_id) as DbUser | undefined; if (!before) throw new Error(`No player with telegram_id ${telegram_id}.`); db.prepare(`DELETE FROM users WHERE telegram_id = ?`).run(telegram_id); return { removed: before }; }, }), tool({ name: "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.", parameters: {}, requiresAdmin: true, handler: async (_a, { db }) => { const rows = db.prepare( "SELECT telegram_id, username, name, first_seen, last_seen FROM seen_users ORDER BY last_seen DESC", ).all(); return { count: rows.length, seen: rows }; }, }), ];