Compare commits
20 Commits
backup-202
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 978211dc65 | |||
| 3647174c30 | |||
| 633a4443c2 | |||
| a9a8bbe3dd | |||
| aae9ddd429 | |||
| 7217e3553b | |||
| f1d4fb596a | |||
| 8ae89610f3 | |||
| 811bf582c6 | |||
| f05db39903 | |||
| 404fee3405 | |||
| b99c36c13c | |||
| bc87681085 | |||
| 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"]
|
||||||
1897
bot/bot.ts
Normal file
1897
bot/bot.ts
Normal file
File diff suppressed because it is too large
Load Diff
19
bot/package.json
Normal file
19
bot/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"sharp": "^0.33.5"
|
||||||
|
},
|
||||||
|
"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 };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
392
mcp/lib/minecraft-wiki-tools.ts
Normal file
392
mcp/lib/minecraft-wiki-tools.ts
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
// Minecraft Wiki tool catalog. Hits https://minecraft.wiki (MediaWiki API).
|
||||||
|
// Composes well with telegram-tools.ts → after wiki_page_images returns a URL
|
||||||
|
// the model can pass it to tg_send_photo to surface a recipe / item image
|
||||||
|
// directly into the chat.
|
||||||
|
import { z } from "zod";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { tool, type Tool } from "./types.ts";
|
||||||
|
|
||||||
|
const WIKI_BASE = "https://minecraft.wiki";
|
||||||
|
const WIKI_API = `${WIKI_BASE}/api.php`;
|
||||||
|
const UA = "Redstone-bot/1.0 (Minecraft container; contact via telegram bot)";
|
||||||
|
const TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
async function wikiGet<T>(params: Record<string, string | number | undefined>): Promise<T> {
|
||||||
|
const url = new URL(WIKI_API);
|
||||||
|
url.searchParams.set("format", "json");
|
||||||
|
url.searchParams.set("formatversion", "2");
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v !== undefined) url.searchParams.set(k, String(v));
|
||||||
|
}
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
headers: { "User-Agent": UA, Accept: "application/json" },
|
||||||
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`wiki ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageUrl(title: string): string {
|
||||||
|
return `${WIKI_BASE}/w/${encodeURIComponent(title.replace(/ /g, "_"))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function absolutize(url: string): string {
|
||||||
|
if (url.startsWith("//")) return `https:${url}`;
|
||||||
|
if (url.startsWith("/")) return `${WIKI_BASE}${url}`;
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the rendered HTML of a Minecraft wiki page and pull out:
|
||||||
|
// - the 3×3 input grid as [[url|null, …], …] (9 slots, in reading order)
|
||||||
|
// - the result item URL (the contents of <span class="mcui-output">)
|
||||||
|
// Returns null when the page has no `mcui-Crafting_Table` block (uncraftable,
|
||||||
|
// or only smelting / brewing / smithing recipes, which use other mcui classes).
|
||||||
|
function parseCraftingGrid(html: string): { grid: (string | null)[][]; result: string | null } | null {
|
||||||
|
const tableMatch = html.match(/<table\b[^>]*data-description="Crafting recipes"[^>]*>([\s\S]*?)<\/table>/);
|
||||||
|
if (!tableMatch) return null;
|
||||||
|
const tableHtml = tableMatch[1];
|
||||||
|
// The crafting widget: <span class="mcui mcui-Crafting_Table …"> … </span>. Find the input + output sections.
|
||||||
|
const widget = tableHtml.match(/<span class="mcui mcui-Crafting[^"]*"[^>]*>([\s\S]*?)<\/span>\s*<\/div>/);
|
||||||
|
if (!widget) return null;
|
||||||
|
const w = widget[1];
|
||||||
|
|
||||||
|
// Each slot starts with `<span class="invslot">`. Splitting on that marker gives us one chunk per slot
|
||||||
|
// (the first chunk is whatever preceded the first invslot — discarded). An empty slot's chunk starts
|
||||||
|
// with `</span>` immediately; a filled slot's chunk contains an <img src="..."> first.
|
||||||
|
const chunks = w.split('<span class="invslot">').slice(1);
|
||||||
|
const allSlots: (string | null)[] = chunks.map((chunk) => {
|
||||||
|
if (chunk.startsWith("</span>")) return null;
|
||||||
|
const img = chunk.match(/<img\b[^>]*\bsrc="([^"]+)"/);
|
||||||
|
return img ? absolutize(img[1]) : null;
|
||||||
|
});
|
||||||
|
// The output uses `invslot invslot-large`, but the split keys on `invslot">` so the trailing
|
||||||
|
// -large slot is split too. The first 9 slots are the input grid; the next non-null is the
|
||||||
|
// result (any "auxiliary" empty filler slots between input and output are tolerable to skip).
|
||||||
|
if (allSlots.length < 9) return null;
|
||||||
|
const inputs = allSlots.slice(0, 9);
|
||||||
|
const result = allSlots.slice(9).find((s) => s != null) ?? null;
|
||||||
|
return {
|
||||||
|
grid: [inputs.slice(0, 3), inputs.slice(3, 6), inputs.slice(6, 9)],
|
||||||
|
result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBytes(url: string): Promise<Buffer> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { "User-Agent": UA },
|
||||||
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${url} → ${res.status}`);
|
||||||
|
return Buffer.from(await res.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose a Minecraft-style crafting recipe PNG. Layout (each cell 64×64 px,
|
||||||
|
// scaled up from the wiki's native 32 px for clarity on phones):
|
||||||
|
// [grid 3×3] [arrow "→"] [result]
|
||||||
|
// Background is the classic inventory gray (#8b8b8b) with darker slot borders.
|
||||||
|
const CELL = 64;
|
||||||
|
const GAP = 4;
|
||||||
|
const PAD = 8;
|
||||||
|
const ARROW_W = 56;
|
||||||
|
async function composeRecipePng(grid: (string | null)[][], result: string | null): Promise<Buffer> {
|
||||||
|
// Collect unique URLs and fetch each once.
|
||||||
|
const unique = new Set<string>();
|
||||||
|
for (const row of grid) for (const u of row) if (u) unique.add(u);
|
||||||
|
if (result) unique.add(result);
|
||||||
|
const blobs: Record<string, Buffer> = {};
|
||||||
|
await Promise.all(
|
||||||
|
[...unique].map(async (u) => {
|
||||||
|
blobs[u] = await fetchBytes(u);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const gridW = CELL * 3 + GAP * 2;
|
||||||
|
const gridH = CELL * 3 + GAP * 2;
|
||||||
|
const totalW = PAD + gridW + (result ? PAD + ARROW_W + PAD + CELL : 0) + PAD;
|
||||||
|
const totalH = PAD + gridH + PAD;
|
||||||
|
|
||||||
|
// Resize every needed sprite to CELL×CELL up front, with nearest-neighbour so
|
||||||
|
// the 16-pixel inventory icons stay crisp (no antialiasing). Sharp's input
|
||||||
|
// is the original buffer; outputs are 64×64 PNG buffers.
|
||||||
|
const sized: Record<string, Buffer> = {};
|
||||||
|
await Promise.all(
|
||||||
|
[...unique].map(async (u) => {
|
||||||
|
sized[u] = await sharp(blobs[u]).resize(CELL, CELL, { kernel: "nearest" }).png().toBuffer();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Arrow rendered as SVG → PNG.
|
||||||
|
const arrowSvg = Buffer.from(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${ARROW_W}" height="${CELL}" viewBox="0 0 ${ARROW_W} ${CELL}">` +
|
||||||
|
`<text x="${ARROW_W / 2}" y="${CELL / 2 + 16}" text-anchor="middle" font-family="serif" font-size="48" fill="#222">→</text>` +
|
||||||
|
`</svg>`,
|
||||||
|
);
|
||||||
|
const arrowPng = await sharp(arrowSvg).png().toBuffer();
|
||||||
|
|
||||||
|
const composites: { input: Buffer; top: number; left: number }[] = [];
|
||||||
|
// Slot backgrounds (slightly darker squares to look like inventory cells).
|
||||||
|
for (let r = 0; r < 3; r++) {
|
||||||
|
for (let c = 0; c < 3; c++) {
|
||||||
|
const left = PAD + c * (CELL + GAP);
|
||||||
|
const top = PAD + r * (CELL + GAP);
|
||||||
|
composites.push({
|
||||||
|
input: Buffer.from(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${CELL}" height="${CELL}">` +
|
||||||
|
`<rect width="${CELL}" height="${CELL}" fill="#6f6f6f" stroke="#3b3b3b" stroke-width="2"/>` +
|
||||||
|
`</svg>`,
|
||||||
|
),
|
||||||
|
top, left,
|
||||||
|
});
|
||||||
|
const u = grid[r][c];
|
||||||
|
if (u) composites.push({ input: sized[u], top, left });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result) {
|
||||||
|
const ax = PAD + gridW + PAD;
|
||||||
|
const rx = ax + ARROW_W + PAD;
|
||||||
|
composites.push({ input: arrowPng, top: PAD + (gridH - CELL) / 2, left: ax });
|
||||||
|
composites.push({
|
||||||
|
input: Buffer.from(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${CELL}" height="${CELL}">` +
|
||||||
|
`<rect width="${CELL}" height="${CELL}" fill="#6f6f6f" stroke="#3b3b3b" stroke-width="2"/>` +
|
||||||
|
`</svg>`,
|
||||||
|
),
|
||||||
|
top: PAD + (gridH - CELL) / 2,
|
||||||
|
left: rx,
|
||||||
|
});
|
||||||
|
composites.push({ input: sized[result], top: PAD + (gridH - CELL) / 2, left: rx });
|
||||||
|
}
|
||||||
|
|
||||||
|
return sharp({
|
||||||
|
create: { width: totalW, height: totalH, channels: 4, background: { r: 139, g: 139, b: 139, alpha: 1 } },
|
||||||
|
})
|
||||||
|
.composite(composites)
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const minecraftWikiTools: Tool<z.ZodRawShape>[] = [
|
||||||
|
tool({
|
||||||
|
name: "wiki_search",
|
||||||
|
title: "Search the Minecraft wiki",
|
||||||
|
description:
|
||||||
|
"Search minecraft.wiki for pages matching a query. Returns top matches with title, short description, and canonical page URL. Use this first to find the exact page title before calling wiki_page or wiki_page_images.",
|
||||||
|
parameters: {
|
||||||
|
query: z.string().min(1).describe("Search terms, e.g. 'iron pickaxe', 'crafting table', 'beacon'"),
|
||||||
|
limit: z.number().int().min(1).max(15).optional().describe("Max results (default 5)"),
|
||||||
|
},
|
||||||
|
handler: async ({ query, limit }) => {
|
||||||
|
// opensearch returns a 4-tuple: [query, titles[], descriptions[], urls[]]
|
||||||
|
type OpenSearch = [string, string[], string[], string[]];
|
||||||
|
const data = await wikiGet<OpenSearch>({
|
||||||
|
action: "opensearch",
|
||||||
|
search: query,
|
||||||
|
limit: limit ?? 5,
|
||||||
|
namespace: 0,
|
||||||
|
});
|
||||||
|
const [, titles, descriptions, urls] = data;
|
||||||
|
return {
|
||||||
|
results: titles.map((title, i) => ({
|
||||||
|
title,
|
||||||
|
description: descriptions[i] || "",
|
||||||
|
url: urls[i] || pageUrl(title),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
tool({
|
||||||
|
name: "wiki_page",
|
||||||
|
title: "Get a Minecraft wiki page summary",
|
||||||
|
description:
|
||||||
|
"Fetch the intro extract (plain text) plus the page's thumbnail image URL and canonical URL. Good for a one-line answer about an item, mob, or mechanic, plus a representative image to send.",
|
||||||
|
parameters: {
|
||||||
|
title: z.string().min(1).describe("Exact wiki page title (use wiki_search first if unsure)"),
|
||||||
|
thumb_size: z.number().int().min(120).max(1200).optional().describe("Thumbnail pixel width (default 600)"),
|
||||||
|
},
|
||||||
|
handler: async ({ title, thumb_size }) => {
|
||||||
|
type PageRes = {
|
||||||
|
query?: {
|
||||||
|
pages?: {
|
||||||
|
title: string;
|
||||||
|
missing?: boolean;
|
||||||
|
extract?: string;
|
||||||
|
thumbnail?: { source: string; width: number; height: number };
|
||||||
|
pageimage?: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const data = await wikiGet<PageRes>({
|
||||||
|
action: "query",
|
||||||
|
prop: "extracts|pageimages",
|
||||||
|
titles: title,
|
||||||
|
exintro: "1",
|
||||||
|
explaintext: "1",
|
||||||
|
exchars: 600,
|
||||||
|
piprop: "thumbnail|name",
|
||||||
|
pithumbsize: thumb_size ?? 600,
|
||||||
|
redirects: "1",
|
||||||
|
});
|
||||||
|
const page = data.query?.pages?.[0];
|
||||||
|
if (!page || page.missing) return { error: `no wiki page titled '${title}'` };
|
||||||
|
return {
|
||||||
|
title: page.title,
|
||||||
|
extract: page.extract ?? "",
|
||||||
|
thumbnail_url: page.thumbnail?.source ?? null,
|
||||||
|
page_url: pageUrl(page.title),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
tool({
|
||||||
|
name: "wiki_recipe_image",
|
||||||
|
title: "Send a Minecraft crafting recipe image to a chat",
|
||||||
|
description:
|
||||||
|
"Generate a 3×3 crafting-grid image of the recipe (with the result item beside an arrow) and send it directly to a Telegram chat. Use this for 'send me the recipe' / 'image of recipe' requests instead of chaining wiki_recipe + tg_send_photo — minecraft.wiki has no standalone recipe PNG, so this tool composites one from the per-slot ingredient sprites. Returns { sent: true, message_id } on success. Fails if the page has no 3×3 crafting recipe (smelting, brewing, smithing all use different mcui widgets).",
|
||||||
|
parameters: {
|
||||||
|
title: z.string().min(1).describe("Page title — e.g. 'Anvil', 'Beacon', 'Crafting Table'"),
|
||||||
|
chat: z.union([z.number().int(), z.string()]).describe("Telegram chat id to send the photo into"),
|
||||||
|
caption: z.string().max(1024).optional().describe("Optional caption — defaults to the page title if omitted"),
|
||||||
|
},
|
||||||
|
handler: async ({ title, chat, caption }, { tg }) => {
|
||||||
|
type ParseRes = { parse?: { title?: string; text?: string } };
|
||||||
|
const data = await wikiGet<ParseRes>({
|
||||||
|
action: "parse",
|
||||||
|
page: title,
|
||||||
|
prop: "text",
|
||||||
|
redirects: "1",
|
||||||
|
disableeditsection: "1",
|
||||||
|
});
|
||||||
|
const html = data.parse?.text ?? "";
|
||||||
|
if (!html) return { error: `no wiki page titled '${title}'` };
|
||||||
|
const resolved = data.parse?.title ?? title;
|
||||||
|
const parsed = parseCraftingGrid(html);
|
||||||
|
if (!parsed) {
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
`no 3×3 crafting recipe on '${resolved}' (page may use smelting / brewing / smithing, or item is uncraftable)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const png = await composeRecipePng(parsed.grid, parsed.result);
|
||||||
|
const sent = await tg.sendPhotoBytes({
|
||||||
|
chat_id: chat,
|
||||||
|
bytes: png,
|
||||||
|
filename: `${resolved.replace(/[^A-Za-z0-9._-]+/g, "_")}_recipe.png`,
|
||||||
|
caption: caption ?? `${resolved} — crafting recipe`,
|
||||||
|
});
|
||||||
|
return { sent: true, message_id: sent.message_id, title: resolved };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
tool({
|
||||||
|
name: "wiki_recipe",
|
||||||
|
title: "Get a crafting recipe from the Minecraft wiki",
|
||||||
|
description:
|
||||||
|
"Extracts the crafting recipe ingredient list from a wiki page (parses the live recipe table — minecraft.wiki renders recipes as HTML grids, not standalone images, so wiki_page_images is the wrong tool for recipe queries). Returns the ingredients string for each recipe variant, plus the page's main item thumbnail URL to send with tg_send_photo. Use this whenever a user asks 'how do I craft X' or 'recipe for X'.",
|
||||||
|
parameters: {
|
||||||
|
title: z.string().min(1).describe("Exact wiki page title — e.g. 'Anvil', 'Beacon', 'Crafting Table'. Use wiki_search first if unsure."),
|
||||||
|
},
|
||||||
|
handler: async ({ title }) => {
|
||||||
|
// 1. Parsed HTML — the recipe lives in a wikitable with data-description="Crafting recipes"
|
||||||
|
type ParseRes = { parse?: { title?: string; text?: string } };
|
||||||
|
const data = await wikiGet<ParseRes>({
|
||||||
|
action: "parse",
|
||||||
|
page: title,
|
||||||
|
prop: "text",
|
||||||
|
redirects: "1",
|
||||||
|
disableeditsection: "1",
|
||||||
|
});
|
||||||
|
const html = data.parse?.text ?? "";
|
||||||
|
if (!html) return { error: `no wiki page titled '${title}'` };
|
||||||
|
const resolvedTitle = data.parse?.title ?? title;
|
||||||
|
|
||||||
|
// 2. Find each crafting-recipes table and pull the first <td> (ingredients) of each data row.
|
||||||
|
const stripHtml = (s: string) =>
|
||||||
|
s.replace(/<br\s*\/?>/gi, " + ")
|
||||||
|
.replace(/<[^>]+>/g, "")
|
||||||
|
.replace(/ | /g, " ")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/\s*\+\s*/g, " + ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
const tableRe = /<table\b[^>]*data-description="Crafting recipes"[^>]*>([\s\S]*?)<\/table>/g;
|
||||||
|
const recipes: { ingredients: string }[] = [];
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = tableRe.exec(html)) !== null) {
|
||||||
|
const rowRe = /<tr\b[^>]*>([\s\S]*?)<\/tr>/g;
|
||||||
|
let r: RegExpExecArray | null;
|
||||||
|
while ((r = rowRe.exec(m[1])) !== null) {
|
||||||
|
if (/<th\b/.test(r[1])) continue;
|
||||||
|
const td = /<td\b[^>]*>([\s\S]*?)<\/td>/.exec(r[1]);
|
||||||
|
if (!td) continue;
|
||||||
|
const text = stripHtml(td[1]);
|
||||||
|
if (text) recipes.push({ ingredients: text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Pull the page thumbnail in a second query — gives the bot a URL to send.
|
||||||
|
type ThumbRes = { query?: { pages?: { title: string; thumbnail?: { source: string } }[] } };
|
||||||
|
const meta = await wikiGet<ThumbRes>({
|
||||||
|
action: "query",
|
||||||
|
prop: "pageimages",
|
||||||
|
titles: resolvedTitle,
|
||||||
|
piprop: "thumbnail",
|
||||||
|
pithumbsize: 600,
|
||||||
|
redirects: "1",
|
||||||
|
});
|
||||||
|
const thumbnail = meta.query?.pages?.[0]?.thumbnail?.source ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: resolvedTitle,
|
||||||
|
recipes, // [{ ingredients: "Block of Iron + Iron Ingot" }, ...]
|
||||||
|
thumbnail_url: thumbnail, // page's main item image — pass to tg_send_photo
|
||||||
|
page_url: pageUrl(resolvedTitle),
|
||||||
|
note: recipes.length === 0
|
||||||
|
? "No recipe table on this page — the item may be uncraftable, found only via loot/structure, or listed under a different page."
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
tool({
|
||||||
|
name: "wiki_page_images",
|
||||||
|
title: "List images on a Minecraft wiki page (with full URLs)",
|
||||||
|
description:
|
||||||
|
"Returns every image embedded on a wiki page along with its direct https URL. Use the optional `filter` to narrow by filename substring (case-insensitive) — pass 'recipe' or 'craft' or 'grid' to surface crafting-recipe diagrams. Hand a URL from this result to tg_send_photo to deliver the image into a chat.",
|
||||||
|
parameters: {
|
||||||
|
title: z.string().min(1).describe("Exact wiki page title"),
|
||||||
|
filter: z.string().optional().describe("Case-insensitive substring filter on the filename (e.g. 'recipe', 'craft', 'grid')"),
|
||||||
|
limit: z.number().int().min(1).max(50).optional().describe("Max images to return (default 20)"),
|
||||||
|
},
|
||||||
|
handler: async ({ title, filter, limit }) => {
|
||||||
|
type ImgRes = {
|
||||||
|
query?: {
|
||||||
|
pages?: {
|
||||||
|
title: string;
|
||||||
|
imageinfo?: { url: string; mime?: string; width?: number; height?: number }[];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const data = await wikiGet<ImgRes>({
|
||||||
|
action: "query",
|
||||||
|
generator: "images",
|
||||||
|
titles: title,
|
||||||
|
prop: "imageinfo",
|
||||||
|
iiprop: "url|mime|size",
|
||||||
|
gimlimit: 50,
|
||||||
|
redirects: "1",
|
||||||
|
});
|
||||||
|
const pages = data.query?.pages ?? [];
|
||||||
|
let images = pages.map((p) => ({
|
||||||
|
filename: p.title.replace(/^File:/, ""),
|
||||||
|
url: p.imageinfo?.[0]?.url ?? "",
|
||||||
|
mime: p.imageinfo?.[0]?.mime,
|
||||||
|
})).filter((i) => i.url && (!i.mime || i.mime.startsWith("image/")));
|
||||||
|
if (filter) {
|
||||||
|
const needle = filter.toLowerCase();
|
||||||
|
images = images.filter((i) => i.filename.toLowerCase().includes(needle));
|
||||||
|
}
|
||||||
|
return { images: images.slice(0, limit ?? 20) };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
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) };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
166
mcp/lib/types.ts
Normal file
166
mcp/lib/types.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
// 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>;
|
||||||
|
/**
|
||||||
|
* Upload a binary photo to Telegram via multipart/form-data sendPhoto.
|
||||||
|
* Use for synthesized images (e.g. composited recipe grids) that don't
|
||||||
|
* live at a public URL Telegram can fetch itself.
|
||||||
|
*/
|
||||||
|
sendPhotoBytes: (params: {
|
||||||
|
chat_id: number | string;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
filename?: string;
|
||||||
|
caption?: string;
|
||||||
|
parse_mode?: "HTML" | "MarkdownV2";
|
||||||
|
reply_to_message_id?: number;
|
||||||
|
}) => Promise<{ message_id: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
async sendPhotoBytes({ chat_id, bytes, filename = "photo.png", caption, parse_mode, reply_to_message_id }) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("chat_id", String(chat_id));
|
||||||
|
if (caption !== undefined) fd.set("caption", caption);
|
||||||
|
if (parse_mode !== undefined) fd.set("parse_mode", parse_mode);
|
||||||
|
if (reply_to_message_id !== undefined) {
|
||||||
|
fd.set("reply_parameters", JSON.stringify({ message_id: reply_to_message_id }));
|
||||||
|
}
|
||||||
|
fd.set("photo", new Blob([bytes], { type: "image/png" }), filename);
|
||||||
|
const res = await fetch(`${base}/sendPhoto`, {
|
||||||
|
method: "POST",
|
||||||
|
body: fd,
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as { ok: boolean; result?: { message_id: number }; description?: string; error_code?: number };
|
||||||
|
if (!data.ok) throw new Error(`sendPhoto(bytes): ${data.description} (code ${data.error_code})`);
|
||||||
|
return data.result!;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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: {} },
|
||||||
|
};
|
||||||
|
}
|
||||||
19
mcp/package.json
Normal file
19
mcp/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "minecraft-mcp",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "bun run server.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
|
"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