From 945b9733a7f4b89e9750ff136fa35c48d02ca8d8 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Mon, 25 May 2026 10:35:23 +0300 Subject: [PATCH] Bootstrap Mineflayer bridge scaffold --- .pi/settings.json | 5 + README.md | 2 +- extensions/mineflayer-bridge.ts | 404 ++++++++++++++++++++++++++++++++ 3 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 .pi/settings.json create mode 100644 extensions/mineflayer-bridge.ts diff --git a/.pi/settings.json b/.pi/settings.json new file mode 100644 index 0000000..26a8e5d --- /dev/null +++ b/.pi/settings.json @@ -0,0 +1,5 @@ +{ + "packages": [ + "../extensions/mineflayer-bridge.ts" + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 6204688..65d6737 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ These are mirrored in `AGENTS.md` and re-stated at the top of any system prompt ## Status -🌱 **Seedling.** The repo currently ships only the scaffold and the initial mandate. The first real milestone is: agent connects to the server configured in `.env`, handles whatever login flow that server requires, and sends `hello` in chat. Everything past that emerges from interaction. +🌿 **Bridge scaffolded.** The repo now includes a project-local Mineflayer bridge extension that registers `mc_chat`, `mc_position`, and `mc_disconnect`, reads `.env`, enforces chat rate limits, and attempts AuthMe-style onboarding. Current live connection is blocked before spawn because the configured server requires a protocol newer than the installed Mineflayer stack supports. The next milestone remains: spawn successfully, handle in-game auth if present, and send `hello` in chat. ## License diff --git a/extensions/mineflayer-bridge.ts b/extensions/mineflayer-bridge.ts new file mode 100644 index 0000000..7f3fe06 --- /dev/null +++ b/extensions/mineflayer-bridge.ts @@ -0,0 +1,404 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { createRequire } from "node:module"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +import type { Bot } from "mineflayer"; + +const require = createRequire(import.meta.url); +const dotenv = require("dotenv") as typeof import("dotenv"); +const mineflayer = require("mineflayer") as typeof import("mineflayer"); + +type AuthMode = "offline" | "microsoft"; +type AuthObservation = + | "not-started" + | "pending" + | "none-detected" + | "register-prompt-password-missing" + | "login-prompt-password-missing" + | "handled-register" + | "handled-login"; + +interface BridgeConfig { + host: string; + port: number; + username: string; + auth: AuthMode; + version: string | false; + authmePassword: string; + chatRateLimitPerMinute: number; + stateDir: string; + redactions: string[]; +} + +const CHAT_PARAMS = { + type: "object", + properties: { + text: { + type: "string", + description: "Plain chat text or a slash command to send in Minecraft. Do not include secrets.", + }, + }, + required: ["text"], + additionalProperties: false, +} as const; + +const EMPTY_PARAMS = { + type: "object", + properties: {}, + additionalProperties: false, +} as const; + +function required(parsed: Record, key: string): string { + const value = parsed[key]?.trim(); + if (!value) throw new Error(`Missing required .env key: ${key}`); + return value; +} + +function sanitizePathSegment(value: string): string { + const cleaned = value.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, ""); + return cleaned.slice(0, 128) || "server"; +} + +function loadConfig(cwd: string): BridgeConfig { + const envPath = resolve(cwd, ".env"); + if (!existsSync(envPath)) { + throw new Error("Missing .env. Copy .env.example to .env and fill in the Minecraft settings."); + } + + const envText = readFileSync(envPath, "utf8"); + const parsed = dotenv.parse(envText) as Record; + dotenv.config({ path: envPath }); + + const host = required(parsed, "MC_HOST"); + const rawPort = required(parsed, "MC_PORT"); + const port = Number.parseInt(rawPort, 10); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error("MC_PORT must be an integer between 1 and 65535."); + } + + const username = required(parsed, "MC_USERNAME"); + const authValue = required(parsed, "MC_AUTH_MODE").toLowerCase(); + if (authValue !== "offline" && authValue !== "microsoft") { + throw new Error("MC_AUTH_MODE must be either offline or microsoft."); + } + + const versionRaw = required(parsed, "MC_VERSION"); + const version = versionRaw.toLowerCase() === "auto" ? false : versionRaw; + + const chatLimitRaw = parsed.CHAT_RATE_LIMIT_PER_MIN?.trim() || "15"; + const chatRateLimitPerMinute = Number.parseInt(chatLimitRaw, 10); + if (!Number.isInteger(chatRateLimitPerMinute) || chatRateLimitPerMinute < 1) { + throw new Error("CHAT_RATE_LIMIT_PER_MIN must be a positive integer."); + } + + const redactions = Object.values(parsed) + .map((value) => value.trim()) + .filter((value) => value.length > 0) + .sort((a, b) => b.length - a.length); + + return { + host, + port, + username, + auth: authValue, + version, + authmePassword: parsed.MC_AUTHME_PASSWORD?.trim() || "", + chatRateLimitPerMinute, + stateDir: resolve(cwd, "state", sanitizePathSegment(`${host}_${port}`)), + redactions, + }; +} + +function stringifyUnknown(value: unknown): string { + if (value instanceof Error) return value.stack || value.message; + if (typeof value === "string") return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function redact(text: string, config: BridgeConfig | undefined): string { + if (!config) return text; + let result = text; + for (const value of config.redactions) { + result = result.split(value).join(`[redacted]`); + } + return result; +} + +function truncate(text: string, maxLength = 800): string { + return text.length <= maxLength ? text : `${text.slice(0, maxLength)}…`; +} + +export default function mineflayerBridge(pi: ExtensionAPI) { + let cwd = process.cwd(); + let config: BridgeConfig | undefined; + let bot: Bot | undefined; + let connecting = false; + let authObservation: AuthObservation = "not-started"; + let authTimer: NodeJS.Timeout | undefined; + let outgoingChatTimestamps: number[] = []; + + function log(event: string, detail?: unknown) { + const suffix = detail === undefined ? "" : `: ${truncate(redact(stringifyUnknown(detail), config))}`; + console.log(`[mineflayer-bridge] ${event}${suffix}`); + } + + function ensureConfig(): BridgeConfig { + if (!config) config = loadConfig(cwd); + return config; + } + + function flagPath(current: BridgeConfig): string { + return resolve(current.stateDir, "joined-before.flag"); + } + + function markJoinedBefore(current: BridgeConfig) { + const path = flagPath(current); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, "joined-before\n", "utf8"); + } + + function hasJoinedBefore(current: BridgeConfig): boolean { + return existsSync(flagPath(current)); + } + + function assertNoEnvLeak(text: string, current: BridgeConfig) { + for (const value of current.redactions) { + if (text.includes(value)) { + throw new Error("Refusing to send Minecraft chat containing a value from .env."); + } + } + } + + function enforceChatRateLimit(current: BridgeConfig) { + const now = Date.now(); + outgoingChatTimestamps = outgoingChatTimestamps.filter((timestamp) => now - timestamp < 60_000); + if (outgoingChatTimestamps.length >= current.chatRateLimitPerMinute) { + throw new Error("Minecraft chat rate limit reached; wait before sending more chat."); + } + outgoingChatTimestamps.push(now); + } + + function activeBot(): Bot { + if (!bot) throw new Error("Mineflayer bot is not connected."); + return bot; + } + + function sendChat(text: string, options: { internalAuthCommand?: boolean } = {}) { + const current = ensureConfig(); + if (!options.internalAuthCommand) assertNoEnvLeak(text, current); + enforceChatRateLimit(current); + activeBot().chat(text); + } + + function stopAuthTimer() { + if (authTimer) clearTimeout(authTimer); + authTimer = undefined; + } + + function startAuthDetection() { + stopAuthTimer(); + authObservation = "pending"; + authTimer = setTimeout(() => { + if (authObservation === "pending") { + authObservation = "none-detected"; + log("auth", "no in-game auth prompt detected within 5s"); + } + }, 5_000); + } + + function maybeHandleAuthPrompt(messageText: string) { + if (!bot) return; + const current = ensureConfig(); + if (authObservation !== "pending") return; + + const lower = messageText.toLowerCase(); + const sawRegister = lower.includes("/register"); + const sawLogin = lower.includes("/login"); + if (!sawRegister && !sawLogin) return; + + stopAuthTimer(); + + const promptKind = sawRegister && !sawLogin ? "register" : "login"; + if (!current.authmePassword) { + authObservation = promptKind === "register" ? "register-prompt-password-missing" : "login-prompt-password-missing"; + log("auth", "in-game auth prompt detected, but MC_AUTHME_PASSWORD is empty"); + return; + } + + const joinedBefore = hasJoinedBefore(current); + const command = sawLogin ? "login" : joinedBefore ? "login" : "register"; + try { + if (command === "register") { + sendChat(`/register ${current.authmePassword} ${current.authmePassword}`, { internalAuthCommand: true }); + authObservation = "handled-register"; + } else { + sendChat(`/login ${current.authmePassword}`, { internalAuthCommand: true }); + authObservation = "handled-login"; + } + markJoinedBefore(current); + log("auth", `handled ${command} prompt using configured password`); + } catch (error) { + log("auth-error", error); + } + } + + function connect() { + if (bot || connecting) return; + const current = ensureConfig(); + connecting = true; + log("connecting", "opening Mineflayer connection to configured server"); + + const nextBot = mineflayer.createBot({ + host: current.host, + port: current.port, + username: current.username, + auth: current.auth, + version: current.version, + logErrors: false, + }); + + bot = nextBot; + nextBot.once("login", () => { + connecting = false; + }); + nextBot.on("spawn", () => { + connecting = false; + log("spawn"); + startAuthDetection(); + }); + nextBot.on("message", (message) => { + maybeHandleAuthPrompt(message.toString()); + }); + nextBot.on("kicked", (reason) => { + log("kicked", reason); + }); + nextBot.on("error", (error) => { + connecting = false; + stopAuthTimer(); + if (bot === nextBot) bot = undefined; + try { + nextBot.end("connection error"); + } catch { + // Ignore close failures; the original error is logged below. + } + log("error", error); + }); + nextBot.on("end", (reason) => { + connecting = false; + stopAuthTimer(); + if (bot === nextBot) bot = undefined; + log("end", reason); + }); + } + + function disconnect() { + stopAuthTimer(); + if (!bot) return false; + const currentBot = bot as Bot & { quit?: (reason?: string) => void; end?: (reason?: string) => void }; + bot = undefined; + if (typeof currentBot.quit === "function") { + currentBot.quit(); + } else if (typeof currentBot.end === "function") { + currentBot.end("disconnect requested"); + } + return true; + } + + pi.on("session_start", async (_event, ctx) => { + cwd = ctx.cwd; + try { + ensureConfig(); + connect(); + if (ctx.hasUI) ctx.ui.setStatus("mineflayer", "mc: connecting"); + } catch (error) { + log("startup-error", error); + if (ctx.hasUI) ctx.ui.notify(redact(stringifyUnknown(error), config), "error"); + } + }); + + pi.on("session_shutdown", async () => { + disconnect(); + }); + + pi.registerTool({ + name: "mc_chat", + label: "Minecraft Chat", + description: "Send one Minecraft chat message or slash command through the connected Mineflayer bot. Rate-limited and refuses .env values.", + promptSnippet: "Send one rate-limited Minecraft chat message or slash command.", + promptGuidelines: [ + "Use mc_chat only for intentional Minecraft chat; never send secrets, .env values, passwords, API keys, or spam.", + ], + parameters: CHAT_PARAMS, + async execute(_toolCallId, params: { text: string }) { + const text = params.text.trim(); + if (!text) throw new Error("mc_chat text must not be empty."); + sendChat(text); + return { + content: [{ type: "text", text: "Sent one Minecraft chat message." }], + details: { sent: true, length: text.length }, + }; + }, + }); + + pi.registerTool({ + name: "mc_position", + label: "Minecraft Position", + description: "Return the connected bot's current Minecraft position and basic status without exposing .env values.", + promptSnippet: "Report the bot's current Minecraft position and basic status.", + parameters: EMPTY_PARAMS, + async execute() { + const currentBot = activeBot(); + const entity = currentBot.entity; + if (!entity) { + return { + content: [{ type: "text", text: "Connected, but entity position is not available yet." }], + details: { connected: true, authObservation }, + }; + } + + const position = { + x: Number(entity.position.x.toFixed(3)), + y: Number(entity.position.y.toFixed(3)), + z: Number(entity.position.z.toFixed(3)), + }; + + return { + content: [ + { + type: "text", + text: `Position: x=${position.x}, y=${position.y}, z=${position.z}; dimension=${currentBot.game.dimension}; auth=${authObservation}`, + }, + ], + details: { + connected: true, + position, + dimension: currentBot.game.dimension, + health: currentBot.health, + food: currentBot.food, + gameMode: currentBot.game.gameMode, + authObservation, + }, + }; + }, + }); + + pi.registerTool({ + name: "mc_disconnect", + label: "Minecraft Disconnect", + description: "Disconnect the Mineflayer bot and do not reconnect automatically.", + promptSnippet: "Disconnect the Mineflayer bot without auto-reconnecting.", + parameters: EMPTY_PARAMS, + async execute() { + const didDisconnect = disconnect(); + return { + content: [{ type: "text", text: didDisconnect ? "Minecraft disconnect requested." : "Mineflayer bot was not connected." }], + details: { disconnected: didDisconnect }, + }; + }, + }); +}