Drops two new capabilities on top of the existing Telegram bot + Minecraft stack, both built around a shared toolkit so there's one source of truth for the actions either side can take. mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23 Telegram Bot API methods (messaging, reactions, polls, dice, photos, stickers, pins, forum topics, chat config). Runs over stdio. mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed handlers. Imported by both mcp/server.ts (registers each as an MCP tool) and bot/bot.ts (exposes each as a Gemini function declaration), so adding a tool in one place lights it up everywhere. bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone, an in-bot persona driven by gemini-2.5-flash-lite with native function calling. In DMs it always responds; in groups only when @-mentioned or replied to. The tool-use loop (max 4 rounds) lets it decide to send a poll, react with an emoji, roll dice, etc. via the shared handlers rather than just text. Thinking budget zeroed and system prompt locked down so the model doesn't leak its reasoning into replies. docker-compose.yml — adds google_key as a docker secret and passes GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
#!/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<User, []>(`SELECT ${USER_COLS} FROM users ORDER BY role DESC, added_at ASC`),
|
||
userById: db.query<User, [number]>(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`),
|
||
userByMc: db.query<User, [string]>(`SELECT ${USER_COLS} FROM users WHERE minecraft_name = ?`),
|
||
userByUsername: db.query<User, [string]>(`SELECT ${USER_COLS} FROM users WHERE username = ?`),
|
||
pending: db.query<User, []>(
|
||
`SELECT ${USER_COLS} FROM users WHERE status = 'pending' ORDER BY added_at ASC`,
|
||
),
|
||
upsert: db.query<unknown, [number, string | null, Role]>(
|
||
`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<unknown, [string | null, number]>(
|
||
"UPDATE users SET minecraft_name = ? WHERE telegram_id = ?",
|
||
),
|
||
setStatus: db.query<unknown, [Status, number]>(
|
||
"UPDATE users SET status = ? WHERE telegram_id = ?",
|
||
),
|
||
setRole: db.query<unknown, [Role, number]>(
|
||
"UPDATE users SET role = ? WHERE telegram_id = ?",
|
||
),
|
||
del: db.query<unknown, [number]>("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<boolean> {
|
||
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<string, unknown>, tgCtx);
|
||
return json(result);
|
||
} catch (e) {
|
||
return fail(`${t.name}: ${(e as Error).message}`);
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
// ---------- start ----------
|
||
|
||
await server.connect(new StdioServerTransport());
|