Compare commits
7 Commits
backup-202
...
backup-202
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ebbcef76b | |||
| cab5337e3f | |||
| 7976de7da4 | |||
| 7cb432195a | |||
| d22cb73a2c | |||
| 875d6fd6dd | |||
| 9ac3705d5e |
24
.gitignore
vendored
24
.gitignore
vendored
@@ -21,3 +21,27 @@ portforward.pid
|
|||||||
|
|
||||||
# Local Claude Code state
|
# Local Claude Code state
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# Telegram bot
|
||||||
|
bot.token
|
||||||
|
bot/node_modules/
|
||||||
|
bot/bun.lockb
|
||||||
|
bot/bun.lock
|
||||||
|
bot/data/
|
||||||
|
|
||||||
|
# Google Generative Language API key
|
||||||
|
google_key.txt
|
||||||
|
|
||||||
|
# Gitea API token (mounted as a docker secret into the bot for release uploads)
|
||||||
|
gitea.token
|
||||||
|
|
||||||
|
# OpenRouter API key
|
||||||
|
openrouter.token
|
||||||
|
|
||||||
|
# MCP server
|
||||||
|
mcp/node_modules/
|
||||||
|
mcp/bun.lock
|
||||||
|
|
||||||
|
# autossh service key material
|
||||||
|
ssh/spotbot_key
|
||||||
|
ssh/known_hosts
|
||||||
|
|||||||
4
bot/.dockerignore
Normal file
4
bot/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
bun.lockb
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
24
bot/Dockerfile
Normal file
24
bot/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM oven/bun:1-debian AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN bun install --no-save
|
||||||
|
|
||||||
|
FROM oven/bun:1-debian
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# docker CLI (to talk to host docker.sock), ssh + autossh (for portforward)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates curl gnupg openssh-client autossh procps \
|
||||||
|
&& install -m 0755 -d /etc/apt/keyrings \
|
||||||
|
&& curl -fsSL https://download.docker.com/linux/debian/gpg \
|
||||||
|
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
|
||||||
|
&& chmod a+r /etc/apt/keyrings/docker.gpg \
|
||||||
|
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable" \
|
||||||
|
> /etc/apt/sources.list.d/docker.list \
|
||||||
|
&& apt-get update && apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
|
||||||
|
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY bot.ts tsconfig.json* ./
|
||||||
|
|
||||||
|
CMD ["bun", "run", "bot.ts"]
|
||||||
1604
bot/bot.ts
Normal file
1604
bot/bot.ts
Normal file
File diff suppressed because it is too large
Load Diff
18
bot/package.json
Normal file
18
bot/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "minecraft-telegram-bot",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "bun run bot.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@grammyjs/auto-retry": "^2.0.2",
|
||||||
|
"@grammyjs/stream": "^1.0.1",
|
||||||
|
"grammy": "^1.30.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"typescript": "^5.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#Minecraft server properties
|
#Minecraft server properties
|
||||||
#Mon Apr 13 16:37:31 UTC 2026
|
#Thu May 07 17:17:47 UTC 2026
|
||||||
accepts-transfers=false
|
accepts-transfers=false
|
||||||
allow-flight=false
|
allow-flight=false
|
||||||
broadcast-console-to-ops=true
|
broadcast-console-to-ops=true
|
||||||
@@ -48,7 +48,7 @@ player-idle-timeout=0
|
|||||||
prevent-proxy-connections=false
|
prevent-proxy-connections=false
|
||||||
query.port=25565
|
query.port=25565
|
||||||
rate-limit=0
|
rate-limit=0
|
||||||
rcon.password=1539e45bbdd3cee797c79a51
|
rcon.password=95a08280a713c687fb8efae0
|
||||||
rcon.port=25575
|
rcon.port=25575
|
||||||
region-file-compression=deflate
|
region-file-compression=deflate
|
||||||
require-resource-pack=false
|
require-resource-pack=false
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
[{"uuid":"e84e6487-5f30-3493-b553-5359d8267873","name":"Hertz333","expiresOn":"2026-05-13 19:09:05 +0000"},{"uuid":"c7691bb7-41cd-3372-82cd-ef0e9b9033bf","name":"codehac","expiresOn":"2026-05-13 18:39:42 +0000"},{"uuid":"aa22ac3b-3014-306f-a390-8acb9ecd602a","name":"Goudvis_1","expiresOn":"2026-05-13 18:32:04 +0000"},{"uuid":"dd86f659-b0fd-3d49-96ea-38c6923a9049","name":"Wietsche","expiresOn":"2026-05-13 16:38:08 +0000"}]
|
[{"uuid":"e84e6487-5f30-3493-b553-5359d8267873","name":"Hertz333","expiresOn":"2026-06-10 14:31:54 +0000"},{"uuid":"c7691bb7-41cd-3372-82cd-ef0e9b9033bf","name":"codehac","expiresOn":"2026-06-09 19:43:18 +0000"},{"uuid":"dd86f659-b0fd-3d49-96ea-38c6923a9049","name":"Wietsche","expiresOn":"2026-06-04 16:15:44 +0000"},{"uuid":"aa22ac3b-3014-306f-a390-8acb9ecd602a","name":"Goudvis_1","expiresOn":"2026-05-13 18:32:04 +0000"}]
|
||||||
@@ -2,8 +2,8 @@ services:
|
|||||||
minecraft:
|
minecraft:
|
||||||
image: itzg/minecraft-server:latest
|
image: itzg/minecraft-server:latest
|
||||||
container_name: minecraft
|
container_name: minecraft
|
||||||
ports:
|
expose:
|
||||||
- "25565:25565"
|
- "25565"
|
||||||
environment:
|
environment:
|
||||||
EULA: "TRUE"
|
EULA: "TRUE"
|
||||||
ONLINE_MODE: "FALSE"
|
ONLINE_MODE: "FALSE"
|
||||||
@@ -15,3 +15,76 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
|
|
||||||
|
autossh:
|
||||||
|
image: jnovack/autossh:2.0.1
|
||||||
|
container_name: minecraft-autossh
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- minecraft
|
||||||
|
environment:
|
||||||
|
SSH_REMOTE_USER: root
|
||||||
|
SSH_REMOTE_HOST: spotbotdev1.dedicated.co.za
|
||||||
|
SSH_REMOTE_PORT: "22"
|
||||||
|
SSH_BIND_IP: "0.0.0.0" # bind on the *remote* (spotbot) public iface
|
||||||
|
SSH_TUNNEL_PORT: "25565" # spotbot:25565 (public side) …
|
||||||
|
SSH_TARGET_HOST: "minecraft" # → service name on the compose network …
|
||||||
|
SSH_TARGET_PORT: "25565" # → minecraft container's :25565
|
||||||
|
SSH_MODE: "-R"
|
||||||
|
SSH_KEY_FILE: "/id_rsa"
|
||||||
|
SSH_KNOWN_HOSTS_FILE: "/known_hosts"
|
||||||
|
SSH_SERVER_ALIVE_INTERVAL: "20"
|
||||||
|
SSH_SERVER_ALIVE_COUNT_MAX: "3"
|
||||||
|
AUTOSSH_GATETIME: "0"
|
||||||
|
AUTOSSH_PORT: "0" # disable monitoring port; rely on ServerAlive
|
||||||
|
AUTOSSH_LOGLEVEL: "1"
|
||||||
|
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:-gemini-2.5-flash-lite}"
|
||||||
|
# OpenRouter primary; Gemini stays as fallback. `openrouter/free` auto-
|
||||||
|
# selects a free model that supports tool calling for this request.
|
||||||
|
OPENROUTER_API_KEY_FILE: /run/secrets/openrouter_token
|
||||||
|
OPENROUTER_MODEL: "${OPENROUTER_MODEL:-openrouter/free}"
|
||||||
|
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
|
||||||
|
# Gitea release upload from inside the container. The token is mounted
|
||||||
|
# as a docker secret (see `secrets:` below + ./gitea.token, gitignored);
|
||||||
|
# backup.sh reads it via $GITEA_TOKEN_FILE. GITEA_API_BASE / GITEA_REPO
|
||||||
|
# let backup.sh skip its `git config remote.origin.url` parse — needed
|
||||||
|
# because the host's origin URL points at localhost:3000, which the bot
|
||||||
|
# container can't reach.
|
||||||
|
GITEA_TOKEN_FILE: /run/secrets/gitea_token
|
||||||
|
GITEA_API_BASE: "${GITEA_API_BASE:-https://thinkstation-web.spot-bot.co.za/api/v1}"
|
||||||
|
GITEA_REPO: "${GITEA_REPO:-paul/Minecraft-Server}"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- .:/mc
|
||||||
|
- ./bot/data:/data
|
||||||
|
secrets:
|
||||||
|
- bot_token
|
||||||
|
- google_key
|
||||||
|
- gitea_token
|
||||||
|
- openrouter_token
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
bot_token:
|
||||||
|
file: ./bot.token
|
||||||
|
google_key:
|
||||||
|
file: ./google_key.txt
|
||||||
|
gitea_token:
|
||||||
|
file: ./gitea.token
|
||||||
|
openrouter_token:
|
||||||
|
file: ./openrouter.token
|
||||||
|
|||||||
2
mcp/.gitignore
vendored
Normal file
2
mcp/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
bun.lockb
|
||||||
270
mcp/lib/minecraft-tools.ts
Normal file
270
mcp/lib/minecraft-tools.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
// 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 };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
370
mcp/lib/telegram-tools.ts
Normal file
370
mcp/lib/telegram-tools.ts
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
// Telegram tool catalog. Shared types + helpers live in ./types.ts.
|
||||||
|
import { z } from "zod";
|
||||||
|
import { tool, type Tool } from "./types.ts";
|
||||||
|
export { createTgClient, toolToGeminiFunction, type ToolCtx, type Tool, type TgClient } from "./types.ts";
|
||||||
|
|
||||||
|
// ---- 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<typeof ButtonSchema>[][] | undefined) {
|
||||||
|
if (!rows || rows.length === 0) return undefined;
|
||||||
|
return {
|
||||||
|
inline_keyboard: rows.map((row) =>
|
||||||
|
row.map((b) => {
|
||||||
|
const out: Record<string, unknown> = { 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 ----
|
||||||
|
|
||||||
|
export const telegramTools: Tool<z.ZodRawShape>[] = [
|
||||||
|
// ---- 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<string, unknown> = {
|
||||||
|
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<string, unknown> = { 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<string, unknown> = {
|
||||||
|
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<string, unknown> = { 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<string, unknown> = { 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) };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
135
mcp/lib/types.ts
Normal file
135
mcp/lib/types.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
// Shared types + helpers used by both telegram-tools and minecraft-tools.
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||||
|
import type { Database } from "bun:sqlite";
|
||||||
|
|
||||||
|
// ---- Telegram HTTP client ----
|
||||||
|
|
||||||
|
export type TgClient = {
|
||||||
|
call: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Minecraft runtime (docker/host shell) ----
|
||||||
|
|
||||||
|
export type ShResult = { ok: boolean; code: number; out: string; err: string };
|
||||||
|
|
||||||
|
export type McConfig = {
|
||||||
|
mcDir: string;
|
||||||
|
composeFile: string;
|
||||||
|
composeProject: string;
|
||||||
|
serverSvc: string;
|
||||||
|
serverContainer: string;
|
||||||
|
pfSvc: string;
|
||||||
|
pfContainer: string;
|
||||||
|
backupSh: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type McRuntime = {
|
||||||
|
config: McConfig;
|
||||||
|
sh: (cmd: string[]) => Promise<ShResult>;
|
||||||
|
compose: (...args: string[]) => Promise<ShResult>;
|
||||||
|
containerRunning: (name: string) => Promise<boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createMcRuntime(config: McConfig): McRuntime {
|
||||||
|
async function sh(cmd: string[]): Promise<ShResult> {
|
||||||
|
const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
|
||||||
|
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() };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
sh,
|
||||||
|
compose: (...args) => sh([
|
||||||
|
"docker", "compose",
|
||||||
|
"--project-directory", config.mcDir,
|
||||||
|
"-p", config.composeProject,
|
||||||
|
"-f", config.composeFile,
|
||||||
|
...args,
|
||||||
|
]),
|
||||||
|
async containerRunning(name) {
|
||||||
|
const r = await sh(["docker", "inspect", "-f", "{{.State.Running}}", name]);
|
||||||
|
return r.ok && r.out === "true";
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Tool definition shape ----
|
||||||
|
|
||||||
|
export type ToolCtx = {
|
||||||
|
tg: TgClient;
|
||||||
|
db: Database;
|
||||||
|
mc: McRuntime;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Tool<Params extends z.ZodRawShape = z.ZodRawShape> = {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
parameters: Params;
|
||||||
|
handler: (args: z.infer<z.ZodObject<Params>>, ctx: ToolCtx) => Promise<unknown>;
|
||||||
|
/** Bot-side: only admins may invoke. MCP server ignores this (caller is trusted). */
|
||||||
|
requiresAdmin?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Strip per-tool shape so a heterogeneous catalog type-checks.
|
||||||
|
export function tool<P extends z.ZodRawShape>(t: Tool<P>): Tool<z.ZodRawShape> {
|
||||||
|
return t as Tool<z.ZodRawShape>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Zod -> Gemini function declaration ----
|
||||||
|
|
||||||
|
function cleanForGemini(s: unknown): unknown {
|
||||||
|
if (Array.isArray(s)) return s.map(cleanForGemini);
|
||||||
|
if (s && typeof s === "object") {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(s as Record<string, unknown>)) {
|
||||||
|
if (k === "$schema" || k === "additionalProperties" || k === "default") continue;
|
||||||
|
// Gemini's schema dialect uses minimum/maximum only, not the exclusive variants.
|
||||||
|
if (k === "exclusiveMinimum" || k === "exclusiveMaximum") continue;
|
||||||
|
if (k === "anyOf" || k === "oneOf") {
|
||||||
|
const variants = (v as unknown[]).filter((x) => x && (x as Record<string, unknown>).type !== "null");
|
||||||
|
if (variants.length === 1) return cleanForGemini(variants[0]);
|
||||||
|
return { type: "string" };
|
||||||
|
}
|
||||||
|
out[k] = cleanForGemini(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toolToGeminiFunction(t: Tool<z.ZodRawShape>) {
|
||||||
|
const schema = zodToJsonSchema(z.object(t.parameters), {
|
||||||
|
$refStrategy: "none",
|
||||||
|
target: "openApi3",
|
||||||
|
});
|
||||||
|
const params = cleanForGemini(schema) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
parameters: params.type ? params : { type: "object", properties: {} },
|
||||||
|
};
|
||||||
|
}
|
||||||
18
mcp/package.json
Normal file
18
mcp/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
71
mcp/server.ts
Normal file
71
mcp/server.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { resolve, dirname } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import { createTgClient, createMcRuntime, type ToolCtx } from "./lib/types.ts";
|
||||||
|
import { telegramTools } from "./lib/telegram-tools.ts";
|
||||||
|
import { minecraftTools } from "./lib/minecraft-tools.ts";
|
||||||
|
|
||||||
|
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 db = new Database(DB_PATH);
|
||||||
|
db.exec("PRAGMA journal_mode = WAL");
|
||||||
|
|
||||||
|
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 ctx: ToolCtx = {
|
||||||
|
tg: TG_TOKEN ? createTgClient(TG_TOKEN) : {
|
||||||
|
call: async () => { throw new Error("No bot token (set BOT_TOKEN or BOT_TOKEN_FILE)."); },
|
||||||
|
},
|
||||||
|
db,
|
||||||
|
mc: createMcRuntime({
|
||||||
|
mcDir: MC_DIR,
|
||||||
|
composeFile: COMPOSE_FILE,
|
||||||
|
composeProject: COMPOSE_PROJECT,
|
||||||
|
serverSvc: SERVER_SVC,
|
||||||
|
serverContainer: SERVER_CONTAINER,
|
||||||
|
pfSvc: PF_SVC,
|
||||||
|
pfContainer: PF_CONTAINER,
|
||||||
|
backupSh: BACKUP_SH,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = new McpServer({ name: "minecraft", version: "0.2.0" });
|
||||||
|
|
||||||
|
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)); }
|
||||||
|
|
||||||
|
for (const t of [...minecraftTools, ...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>, ctx);
|
||||||
|
return json(result);
|
||||||
|
} catch (e) {
|
||||||
|
return fail(`${t.name}: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await server.connect(new StdioServerTransport());
|
||||||
Reference in New Issue
Block a user