20 Commits

Author SHA1 Message Date
978211dc65 fix(redstone): close race in withSlowToolIndicator orphaning wiki loop
The setTimeout callback awaited sendMessage before installing the
rotation interval. If the tool resolved during that await, the finally
cleanup ran before rotateTimer existed, leaving an interval that edited
the "reading the wiki…" placeholder forever. Added a `done` flag so the
callback bails (and deletes the placeholder) if the tool already finished.
2026-05-14 12:42:55 +02:00
3647174c30 feat(redstone): /ai_use — OpenRouter key usage report
Admin-only command. Calls GET https://openrouter.ai/api/v1/key (the
REST equivalent of the @openrouter/sdk's apiKeys.getCurrent()) and
formats the result:

- active model (from the settings table)
- tier (🟢 free / 💰 paid)
- credit limit + remaining (or "none")
- credits spent today / week / month / all-time

Plus a note that free-model requests don't draw credits — they're
capped separately at 50/day (UTC-midnight reset), liftable to
1000/day with $10 credit. Kept fetch-based like the rest of the
OpenRouter integration rather than pulling in the SDK.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 08:23:08 +02:00
633a4443c2 ux(redstone): surface AI provider failure instead of silent drop
When both OpenRouter AND Gemini return null (no key / 429 / 503 / network)
and no tool ran during the turn, the handler used to silently return,
leaving the user staring at their unanswered message wondering if the
bot was broken. Real cause is usually the daily free-tier rate-limit
exhausting + Gemini's intermittent 503s lining up.

Now post a one-liner reply explaining:
  "🤖 AI is unreachable right now — both OpenRouter and Gemini just
  declined. Usually the free-tier daily cap resetting around UTC
  midnight. Try again in a few minutes, or use /model to pick a
  different model."

Only fires when ranTool is false — if the model successfully called
a tool earlier in the turn (image already sent, message posted, etc.)
the user has already seen something visible and we keep the silent
return for that case.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:08:23 +02:00
a9a8bbe3dd ui(redstone): dismiss /model keyboard on selection
selectModel was re-rendering the full picker with the  moved to the
new pick. Per design feedback, drop the keyboard once a choice is made
— the callback toast plus a confirmation body is enough. Keeps the
chat tidy after the switch.

editMessageText without reply_markup clears the inline keyboard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:04:36 +02:00
aae9ddd429 feat(redstone): /model picker (OpenRouter) + fix /clear leaving its own reply
Two changes:

1) /model — admin-only Telegram command that fetches GET
   https://openrouter.ai/api/v1/models, filters to models whose
   supported_parameters includes "tools", pins openrouter/free and
   openrouter/auto to the top, then sorts the rest free-first /
   alpha-by-name. Renders a vertically-stacked InlineKeyboard
   (one model per row, capped at 20) with badges:

      <current>   🟢 free / 💰 paid   🛠 tools   🧠 thinking

   Tap-to-select callback writes the model id into the new
   settings(key,value) table. callOpenRouterOnce resolves the
   model at every call (currentOpenRouterModel() → kvGet ??
   OPENROUTER_MODEL env ?? "openrouter/free"), so the new pick
   takes effect on the very next message — no restart, no
   container rebuild.

2) /clear — was leaving its own confirmation message ("🧹 cleared
   N messages from this chat.") in the DB, so the next /clear
   would count it. Now wipes once, replies, wipes again — the
   chat's messages row count truly stays at 0 across repeats.

Schema migration: CREATE TABLE IF NOT EXISTS settings(key, value,
updated_at) — idempotent, no Bun migration needed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:00:08 +02:00
7217e3553b feat(redstone): wiki_recipe_image — composite real recipe PNG and upload
Previous wiki_recipe only returned the page thumbnail, so "send me the
recipe image" delivered a picture of the item, not the recipe itself.
minecraft.wiki renders recipes as live HTML grids, not standalone PNGs,
so we now generate the image ourselves.

mcp/lib/minecraft-wiki-tools.ts:
- parseCraftingGrid(html): pulls the 3×3 input grid + result item URL
  from <span class="mcui mcui-Crafting_Table">. Each <span class="invslot">
  is either empty (chunk starts with </span>) or has an <img src="/images/
  Invicon_X.png?h">. Reading-order chunks → grid; first non-null after
  the 9th input slot → result.
- composeRecipePng(grid, result): fetches each unique sprite, resizes
  to 64×64 via sharp (nearest-neighbour to keep pixel art crisp),
  composites onto a #8b8b8b inventory-style background with #6f6f6f
  slot squares + dark borders, places an SVG-rendered "→" arrow,
  then the result cell next to it. PNG buffer returned.
- New tool wiki_recipe_image(title, chat, caption?) that runs the
  pipeline end-to-end and uploads via tg.sendPhotoBytes — one tool
  call, image already in the chat when it returns.

mcp/lib/types.ts:
- TgClient gains sendPhotoBytes({ chat_id, bytes, filename?, caption?,
  parse_mode?, reply_to_message_id? }). Multipart/form-data POST to
  sendPhoto, since Telegram's URL-mode can only fetch public URLs and
  the composited PNG only lives in memory. 30s timeout (vs the 20s on
  the JSON call) because uploads are bigger.

mcp/package.json + bot/package.json: add sharp ^0.33.5. mcp/ pulls it
in too so /mc/mcp/lib resolves it at the bot's runtime (the bot's
node_modules is at /app while the lib files live at /mc/mcp/lib).

Persona prompt: wiki_recipe_image is now the FIRST call for any
"recipe" / "send me the recipe image" request. Single tool turn ends
with the photo already in the chat. wiki_recipe stays as a fallback
when the page has no 3×3 crafting widget (smelting / brewing / smithing
use different mcui classes).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:54:20 +02:00
f1d4fb596a feat(redstone): wiki_recipe tool — extract recipe + thumbnail in one call
Adds a new MCP tool that parses minecraft.wiki's <table data-description=
"Crafting recipes"> directly, pulling the ingredients column out of each
row (e.g. "Block of Iron + Iron Ingot" for Anvil), then chases up the
page's main item thumbnail via prop=pageimages so the bot can deliver
text + a real image in one tool round.

Why a dedicated tool: minecraft.wiki does not host standalone "recipe.png"
files — recipes are rendered live as HTML grids composed from individual
BlockSprite icons. Filtering wiki_page_images for 'recipe' / 'craft' /
'grid' returned empty for every craftable item, leaving the model with
nothing to call tg_send_photo with. Confirmed by inspecting the Anvil
page's image inventory (Anvil1-6.png + GUI + sound files, no recipe).

Tool returns {
  title, page_url,
  recipes: [{ ingredients: "…" }, …],   // strips <br>/<a>/&nbsp; cleanly
  thumbnail_url,                          // for tg_send_photo
  note?,                                  // present when no recipe rows
}

Persona prompt tightened:
- wiki_recipe is now the FIRST call for "how do I craft X" / "recipe for X"
- wiki_page_images is downgraded to "specific non-thumbnail images only"
- IMAGE DELIVERY MANDATORY clause: must end the turn with tg_send_photo
  using a real URL or say plainly that no thumbnail was found — no more
  describing-the-image-in-prose-and-stopping failures.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:47:25 +02:00
8ae89610f3 fix(redstone): force image delivery + raise tool-loop ceiling + log tool calls
Reproduced: "send me the recipe for an anvil" got a TEXT reply describing
the recipe but no image. Three changes to fix this class of problem:

1) Iteration cap 4 → 6. The wiki+photo flow is naturally
   wiki_search → wiki_page → wiki_page_images → tg_send_photo → final
   text — five tool calls + one text response. With only 4 iterations
   the model often ran out before reaching tg_send_photo or before
   emitting closing text, returning {text:null, ranTool:true} which
   the handler swallows silently.

2) Persona prompt: image delivery is now MANDATORY when the user asks
   for a picture / image / recipe / "what it looks like". Added an
   explicit fallback chain: filter='recipe' → 'craft' → 'grid' → the
   wiki_page thumbnail_url. The previous phrasing was suggestive only,
   which let the model answer in pure text.

3) Tool-call tracing. Both dispatchers now log `[redstone:or|gem]
   tool -> name args` before each call and `tool <- name ok|err msg`
   after. Without this, silent "model called tools but no image
   showed up" failures had no log line at all and we had to guess.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:41:44 +02:00
811bf582c6 feat(redstone): slow-tool indicator + persona-name addressing
Two changes that came out of a real "I said hey steve, bot didn't reply"
report:

1) Persona-name addressing in groups. botAddressed now ALSO triggers
   when the message text contains the bot's persona name (regex
   /\b(redstone|steve)\b/i, case-insensitive, word-boundary). The
   persona prompt already invites users to call the bot "Redstone" or
   "Steve" — without this matcher, those messages were silently
   dropped in groups because Telegram didn't recognise the plain
   word as a @-mention. Private chats still trigger on every text
   message (unchanged).

2) Slow-tool indicator for wiki_* calls. minecraft.wiki can lag /
   rate-limit. New withSlowToolIndicator(chatId, toolName, fn) wraps
   the tool execution: if the call takes longer than 1500 ms,
   send a "🔍 looking it up…" placeholder message into the chat
   and rotate through a 4-phrase loop ("📖 reading the wiki…",
   "🔎 searching…", "🧭 cross-referencing…") every 900 ms. When
   the tool resolves, the timer + interval are cleared and the
   placeholder is deleted, so fast calls (<1.5 s) leave no trace
   in the chat. Only fires for tools whose name matches /^wiki_/.

Wired through the dispatch chain — runRedstoneTurn,
runRedstoneTurnViaOpenRouter, and runRedstoneTurnViaGemini all
gained a `chatId` parameter so the indicator knows which chat to
post into.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:36:11 +02:00
f05db39903 feat(redstone): connect minecraft.wiki via three new MCP tools
Redstone can now query the official Minecraft wiki and deliver
crafting recipe / item images straight into the chat.

New: mcp/lib/minecraft-wiki-tools.ts exports three tools, all hitting
https://minecraft.wiki/api.php (MediaWiki API, formatversion=2):

- wiki_search(query, limit?) — opensearch top results, namespace 0,
  returns { title, description, url } for each hit.
- wiki_page(title, thumb_size?) — extracts|pageimages → intro extract
  (plain text, ~600 chars) + thumbnail_url + page_url.
- wiki_page_images(title, filter?, limit?) — generator=images +
  prop=imageinfo (iiprop=url|mime|size) returns every image embedded
  on the page along with its direct https URL. The `filter`
  substring is case-insensitive against the filename — pass 'recipe'
  / 'craft' / 'grid' to surface crafting-recipe diagrams.

Bot wiring (bot/bot.ts):
- import minecraftWikiTools, merge into allTools so it flows through
  the existing OpenRouter + Gemini function/tool schemas automatically.
- Persona prompt teaches the model the canonical flow:
  wiki_search → wiki_page → wiki_page_images(filter='recipe') →
  tg_send_photo(url, caption). Composes with the already-existing
  tg_send_photo tool — no new telegram tool needed.

User-Agent on every wiki request, 15s timeout, JSON-only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:29:45 +02:00
404fee3405 ui(bot): simplify /status panel — one power toggle, cleaner layout
Replaces the old "Up All / Server ▾ / Tunnel ▾" five-button grid with a
single power-toggle button (▶️ Start / 🛑 Stop) plus three flat-nav
buttons (Logs / Users / Refresh). Granular per-service control stays
reachable via the existing /server_up, /server_down, /pf_up, /pf_down
slash commands.

Message body redesigned: top-line health pill (🟢 Online / 🟡 Partial /
🔴 Offline), then aligned status rows for Server and Tunnel, then the
update timestamp. Uses the same 🟢/🔴 pair throughout for consistency,
dropping the mixed / + 🟢/🟡 from before.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:20:52 +02:00
b99c36c13c fix(bot): scope /up to server+tunnel — bare compose up -d killed the bot
actUpAll was running `docker compose up -d` with no service filter, so it
re-evaluated every service in the project (including the bot). docker
compose then recreated the bot container, SIGTERM'd the running process
mid-RPC, and left a renamed replacement (`<oldhash>_minecraft-bot`) stuck
in "Created" state because the original container hadn't yet released
the name. End result: /up silently took the bot offline and required
manual `docker rm + docker compose up -d bot` to recover.

Scope to SERVER_SVC + PF_SVC (matching actDownAll which already does the
same thing). The bot can't sensibly restart itself anyway.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:13:42 +02:00
bc87681085 fix(redstone): use plain reply in non-private chats (TEXTDRAFT_PEER_INVALID)
sendMessageDraft (Bot API 9.5) only works in private chats. Group,
supergroup and channel peers reject it with TEXTDRAFT_PEER_INVALID,
so Redstone was silently failing the entire reply path in groups
after the streaming feature landed in cab5337.

Keep replyWithStream for private chats (the typing animation is
the whole point); fall back to plain ctx.reply() everywhere else.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 22:59:09 +02:00
4ebbcef76b feat(redstone): add OpenRouter primary AI with Gemini fallback
Redstone now hits OpenRouter's `openrouter/free` auto-router first,
falling back to direct Gemini if the OpenRouter call fails (no key,
network error, or `:free` upstream rejected tool use). The auto-
router filters its free-model pool by the request's tool-calling
requirement, so we don't have to pin a specific free model.

- bot/bot.ts:
  - OPENROUTER_API_KEY / _FILE env (mirrors GEMINI_API_KEY_FILE)
  - OPENROUTER_MODEL defaults to "openrouter/free"
  - openRouterTools[] derived from existing geminiFunctionDeclarations
    (OpenAI-style {type:"function", function:{name,description,parameters}})
  - callOpenRouterOnce + runRedstoneTurnViaOpenRouter (parses JSON-string
    tool_calls.arguments, replies with role:"tool" + tool_call_id)
  - Existing Gemini path moved into runRedstoneTurnViaGemini
  - runRedstoneTurn dispatches OpenRouter first, Gemini on null
  - Early-return gate now passes if either key is configured

- docker-compose.yml:
  - new openrouter_token secret -> ./openrouter.token (gitignored)
  - OPENROUTER_API_KEY_FILE + OPENROUTER_MODEL env wired to the bot

- .gitignore: add openrouter.token (plus pre-existing gitea.token entry
  that was sitting uncommitted).

The key file itself is NOT committed (verified via git check-ignore).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 22:42:33 +02:00
cab5337e3f stream Redstone replies via sendMessageDraft + fix self-killing server_down
Two related changes to how Redstone behaves in chat.

Streaming: replies now animate word-by-word into Telegram using the new
sendMessageDraft endpoint (Bot API 9.5, March 2026) via the @grammyjs/stream
plugin, with @grammyjs/auto-retry on the API layer to swallow 429s
transparently. The previous editMessageText-based approach is gone —
sendMessageDraft is designed for this and animates natively on the client
without hitting the 1/sec-per-chat edit limit. Pace lives in
STREAM_WORD_DELAY_MS=50; tunable in one spot.

server_down footgun: the MCP tool was running `compose down` with no
service arg, which tore down the whole project — including the bot
container running the tool call, which then got SIGTERM mid-conversation
and didn't come back (unless-stopped doesn't restart on a clean compose
down). Behaviour now matches the existing /stop slash command:
compose stop minecraft autossh, leaving the bot up. Pass an explicit
service to stop just that one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 08:47:35 +02:00
7976de7da4 shape Redstone's voice: hippy dialect, name 'Redstone Steve', persistent typing, stochastic sticker swap
Persona polish that grew out of using the bot in the group.

Voice: replace the generic 'friendly assistant' personality block with
laid-back hippy dialect — 'man', 'groovy', 'good vibes', occasional
loose grammar — wrapped around the existing Minecraft-themed flavor.
Capped to one or two hippy words per reply so it doesn't read as a
caricature; still helpful first, vibe second; dials down if the user
is terse.

Name: persona is now 'Redstone Steve' — combines the mineral and the
default player. Prompt notes it also answers to plain 'Redstone' or
'Steve' so existing chat history isn't suddenly off-character.

Typing indicator: startTypingLoop() re-ticks sendChatAction every 4s
for the whole AI roundtrip (Telegram auto-clears after ~5s), wrapped
in a try/finally so the indicator clears (fades naturally) on both
success and failure paths. Previously it fired once and faded mid-call
on long requests, making the bot look stalled.

Sticker swap: only 35% of palette-emoji matches actually fire a
sticker now (STICKER_SEND_PROBABILITY = 0.35). Keeps stickers as a
treat rather than a tic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:01:44 +02:00
7cb432195a bundle several Redstone QoL fixes: /start+/stop+/clear, auto-collect stickers, emoji→sticker swap
A handful of related Telegram-bot improvements that piled up in one
working session.

Slash menu: /start now brings the whole stack up (was: panel render —
duplicated by /menu) and a new /stop takes it down. /up and /down kept
as undocumented aliases. /clear wipes the current chat's rows from the
messages table — DM-self for users, admin-only in groups — so Redstone's
recentTurns history can be reset on demand.

Sticker auto-collection: every incoming sticker is upserted into a new
sticker_library table (file_id PK, plus emoji, set_name, first_seen,
last_seen, seen_count). No /setsticker needed. The original event-keyed
stickers table is untouched and still drives the bot's own event
triggers (maybeSendSticker("up") etc).

Emoji → sticker swap (replaces the AI-driven sticker tool): instead of
listing 15+ file_ids in the system prompt and exposing tg_send_sticker
as a function call, the prompt now lists just the *emoji palette* the
library knows about. After Redstone replies, pickStickerForReply()
scans the text for the first palette-emoji that appears and fires the
matching sticker as a follow-up via ctx.replyWithSticker — without
inserting a row into the messages table, so the audit log stays
text-only and Redstone's next recentTurns isn't polluted. tg_send_sticker
and tg_stickers_known are filtered out of Redstone's geminiFunctionDeclarations
(still exposed via the MCP server for Claude / external clients).

Reliability: Gemini call timeout bumped from 25s to 45s after a real
timeout in production with the previous (too-large) sticker prompt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:52:00 +02:00
d22cb73a2c extract Minecraft tools to shared toolkit; gate Redstone replies to authorized senders
Redstone previously only saw the 23 tg_* tools — it had no idea the
Minecraft stack existed, so questions like "is the server up?" went
unanswered. This change extracts the 15 Minecraft tools (lifecycle,
rcon, backup, players, seen) into the same shared catalog the Telegram
tools already used, so both Gemini and external MCP clients see them.

mcp/lib/types.ts (new) holds the shared shape: Tool<P>, ToolCtx,
createTgClient, createMcRuntime (a Bun.spawn-based wrapper for docker
compose / docker exec), and toolToGeminiFunction (zod → Gemini schema,
now also stripping exclusiveMinimum/Maximum since Gemini rejects them).

mcp/lib/minecraft-tools.ts (new) is the catalog itself. Eight handlers
are flagged requiresAdmin: rcon, backup, and all destructive player_*
writes plus seen_list. mcp/server.ts trusts the caller (Claude / Paul
on the host) and ignores the flag; bot/bot.ts honours it at dispatch
time, returning {error: "...requires admin role..."} to Gemini so it
can explain to the user instead of attempting the call.

mcp/server.ts shrinks from 423 lines to 70 — a single loop over both
catalogs replaces the hand-rolled registrations.

bot/bot.ts wires both catalogs into the function declarations and adds
the admin gate. It also gains a defensive re-check on every incoming
group/DM text: the Redstone handler now does its own lookup of
ctx.from.id against the users table and refuses to reply unless
status='active'. This is belt-and-braces — the auth middleware already
short-circuits unauthorized callers earlier in the chain, but with
privacy-mode-off groups now feeding every message through, a future
refactor that reorders middleware shouldn't be able to make Redstone
respond to strangers.

Drops thinkingConfig from the Gemini call (gemini-2.5-flash-lite
rejects it outright with HTTP 400).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:19:27 +02:00
875d6fd6dd add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.

mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.

mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.

bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.

docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
9ac3705d5e add telegram bot and autossh tunnel 2026-05-11 21:51:59 +02:00
15 changed files with 3336 additions and 5 deletions

24
.gitignore vendored
View File

@@ -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
View File

@@ -0,0 +1,4 @@
node_modules
bun.lockb
.git
*.log

24
bot/Dockerfile Normal file
View 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

File diff suppressed because it is too large Load Diff

19
bot/package.json Normal file
View 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"
}
}

View File

@@ -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

View File

@@ -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"}]

View File

@@ -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
View File

@@ -0,0 +1,2 @@
node_modules/
bun.lockb

270
mcp/lib/minecraft-tools.ts Normal file
View 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 (316 chars, AZ, 09, _).",
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 (316 chars, AZ, 09, _).`);
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 };
},
}),
];

View 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(/&#160;|&nbsp;/g, " ")
.replace(/&amp;/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
View 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
View 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
View 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
View 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());