266 lines
12 KiB
TypeScript
266 lines
12 KiB
TypeScript
|
|
// 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<z.ZodRawShape>[] = [
|
|||
|
|
// ---- 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: "docker compose down. Pass a service to stop just one (uses `stop` instead of `down`).",
|
|||
|
|
parameters: { service: z.string().optional() },
|
|||
|
|
handler: async ({ service }, { mc }) => {
|
|||
|
|
const r = service ? await mc.compose("stop", service) : await mc.compose("down");
|
|||
|
|
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 };
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
];
|