Two related changes to how Redstone behaves in chat. Streaming: replies now animate word-by-word into Telegram using the new sendMessageDraft endpoint (Bot API 9.5, March 2026) via the @grammyjs/stream plugin, with @grammyjs/auto-retry on the API layer to swallow 429s transparently. The previous editMessageText-based approach is gone — sendMessageDraft is designed for this and animates natively on the client without hitting the 1/sec-per-chat edit limit. Pace lives in STREAM_WORD_DELAY_MS=50; tunable in one spot. server_down footgun: the MCP tool was running `compose down` with no service arg, which tore down the whole project — including the bot container running the tool call, which then got SIGTERM mid-conversation and didn't come back (unless-stopped doesn't restart on a clean compose down). Behaviour now matches the existing /stop slash command: compose stop minecraft autossh, leaving the bot up. Pass an explicit service to stop just that one. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
271 lines
12 KiB
TypeScript
271 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: "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 };
|
||
},
|
||
}),
|
||
];
|