diff --git a/.gitignore b/.gitignore index e7777c0..7f002bb 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,13 @@ portforward.pid # Local Claude Code state .claude/settings.local.json + +# Telegram bot +bot.token +bot/node_modules/ +bot/bun.lockb +bot/data/ + +# autossh service key material +ssh/spotbot_key +ssh/known_hosts diff --git a/bot/.dockerignore b/bot/.dockerignore new file mode 100644 index 0000000..3dba193 --- /dev/null +++ b/bot/.dockerignore @@ -0,0 +1,4 @@ +node_modules +bun.lockb +.git +*.log diff --git a/bot/Dockerfile b/bot/Dockerfile new file mode 100644 index 0000000..e0f16ed --- /dev/null +++ b/bot/Dockerfile @@ -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"] diff --git a/bot/bot.ts b/bot/bot.ts new file mode 100644 index 0000000..94acfb9 --- /dev/null +++ b/bot/bot.ts @@ -0,0 +1,937 @@ +import { Bot, Context, GrammyError, HttpError, InlineKeyboard } from "grammy"; +import { Database } from "bun:sqlite"; +import { readFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +// ---- Config ---- +const TOKEN = ( + process.env.BOT_TOKEN ?? + (process.env.BOT_TOKEN_FILE ? readFileSync(process.env.BOT_TOKEN_FILE, "utf8") : "") +).trim(); +if (!TOKEN) throw new Error("BOT_TOKEN or BOT_TOKEN_FILE must be set"); + +const MC_DIR = process.env.MC_DIR ?? "/mc"; +const COMPOSE_FILE = `${MC_DIR}/docker-compose.yml`; +// Pin the compose project name so we manage the same containers regardless of where the bot runs. +// Without this, compose derives the project from the cwd basename (e.g. "mc" inside the container vs "minecraft" on host). +const COMPOSE_PROJECT = process.env.COMPOSE_PROJECT_NAME ?? "minecraft"; +// CRITICAL: the docker daemon evaluates relative bind-mount paths from this directory, ON THE HOST. +// Without this, ./data and ./ssh/spotbot_key resolve to /mc/data and /mc/ssh/... — paths that don't +// exist on the host, so docker silently creates empty dirs and the world / ssh-key get lost. +const COMPOSE_HOST_DIR = process.env.COMPOSE_HOST_DIR ?? "/home/paul/containers/minecraft"; +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 DB_PATH = process.env.DB_PATH ?? "/data/users.db"; +const ADMIN_BOOTSTRAP = (process.env.ADMIN_BOOTSTRAP ?? "1311866578:Paul").trim(); + +// ---- DB ---- +mkdirSync(dirname(DB_PATH), { recursive: true }); +const db = new Database(DB_PATH); +db.exec("PRAGMA journal_mode = WAL"); +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + telegram_id INTEGER PRIMARY KEY, + name TEXT, + role TEXT NOT NULL CHECK(role IN ('admin','user')) DEFAULT 'user', + added_at TEXT NOT NULL DEFAULT (datetime('now')), + added_by INTEGER + ); + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id INTEGER NOT NULL, + message_id INTEGER, + user_id INTEGER, + direction TEXT NOT NULL CHECK(direction IN ('in','out')), + kind TEXT, + body TEXT, + reply_to INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + edited_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_messages_chat ON messages(chat_id, created_at DESC); + CREATE TABLE IF NOT EXISTS panels ( + chat_id INTEGER NOT NULL, + panel TEXT NOT NULL, + message_id INTEGER NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY(chat_id, panel) + ); + CREATE TABLE IF NOT EXISTS stickers ( + event TEXT PRIMARY KEY, + file_id TEXT NOT NULL, + set_at TEXT NOT NULL DEFAULT (datetime('now')), + set_by INTEGER + ); +`); + +// Idempotent migrations. +function ensureColumn(table: string, col: string, decl: string) { + const cols = db.query<{ name: string }, []>(`PRAGMA table_info(${table})`).all(); + if (!cols.some((c) => c.name === col)) { + db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${decl}`); + } +} +ensureColumn("users", "minecraft_name", "TEXT COLLATE NOCASE"); +ensureColumn("users", "username", "TEXT COLLATE NOCASE"); +ensureColumn("users", "status", "TEXT NOT NULL DEFAULT 'active'"); +db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_mcname + ON users(minecraft_name) WHERE minecraft_name IS NOT NULL`); +db.exec(`CREATE INDEX IF NOT EXISTS idx_users_username + ON users(username) WHERE username IS NOT NULL`); + +// Anyone the bot has seen send a message but who isn't (yet) authorized. +db.exec(` + CREATE TABLE IF NOT EXISTS seen_users ( + telegram_id INTEGER PRIMARY KEY, + username TEXT COLLATE NOCASE, + name TEXT, + first_seen TEXT NOT NULL DEFAULT (datetime('now')), + last_seen TEXT NOT NULL DEFAULT (datetime('now')), + last_chat_id INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_seen_username + ON seen_users(username) WHERE username IS NOT NULL; +`); + +const MC_NAME_RE = /^[A-Za-z0-9_]{3,16}$/; + +type Role = "admin" | "user"; +type Status = "active" | "pending"; +type User = { + telegram_id: number; name: string | null; role: Role; + minecraft_name: string | null; username: string | null; status: Status; +}; + +const USER_COLS = "telegram_id, name, role, minecraft_name, username, status"; + +const Q = { + user: db.query(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`), + userByMc: db.query(`SELECT ${USER_COLS} FROM users WHERE minecraft_name = ?`), + userByUsername: db.query(`SELECT ${USER_COLS} FROM users WHERE username = ?`), + users: db.query(`SELECT ${USER_COLS} FROM users ORDER BY role DESC, added_at ASC`), + setUsername: db.query( + "UPDATE users SET username = ? WHERE telegram_id = ?" + ), + setStatus: db.query( + "UPDATE users SET status = ? WHERE telegram_id = ?" + ), + pendingUsers: db.query( + `SELECT ${USER_COLS} FROM users WHERE status = 'pending' ORDER BY added_at ASC` + ), + seenUpsert: db.query( + `INSERT INTO seen_users (telegram_id, username, name, last_chat_id) VALUES (?, ?, ?, ?) + ON CONFLICT(telegram_id) DO UPDATE SET + username = excluded.username, + name = COALESCE(excluded.name, seen_users.name), + last_chat_id = excluded.last_chat_id, + last_seen = datetime('now')` + ), + seenByUsername: db.query<{ telegram_id: number; username: string | null; name: string | null }, [string]>( + "SELECT telegram_id, username, name FROM seen_users WHERE username = ? ORDER BY last_seen DESC LIMIT 1" + ), + upsertUser: db.query( + `INSERT INTO users (telegram_id, name, role, added_by) VALUES (?, ?, ?, ?) + ON CONFLICT(telegram_id) DO UPDATE SET + name = COALESCE(excluded.name, users.name), role = excluded.role` + ), + setMcName: db.query( + "UPDATE users SET minecraft_name = ? WHERE telegram_id = ?" + ), + delUser: db.query("DELETE FROM users WHERE telegram_id = ?"), + countUsers: db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM users"), + insMsg: db.query( + `INSERT INTO messages (chat_id, message_id, user_id, direction, kind, body, reply_to) VALUES (?, ?, ?, ?, ?, ?, ?)` + ), + markMsgEdited: db.query( + `UPDATE messages SET edited_at = datetime('now') WHERE chat_id = ? AND message_id = ?` + ), + getPanel: db.query<{ message_id: number }, [number, string]>( + "SELECT message_id FROM panels WHERE chat_id = ? AND panel = ?" + ), + setPanel: db.query( + `INSERT INTO panels (chat_id, panel, message_id) VALUES (?, ?, ?) + ON CONFLICT(chat_id, panel) DO UPDATE SET message_id = excluded.message_id, updated_at = datetime('now')` + ), + delPanel: db.query("DELETE FROM panels WHERE chat_id = ? AND panel = ?"), + getSticker: db.query<{ file_id: string }, [string]>("SELECT file_id FROM stickers WHERE event = ?"), + setSticker: db.query( + `INSERT INTO stickers (event, file_id, set_by) VALUES (?, ?, ?) + ON CONFLICT(event) DO UPDATE SET file_id = excluded.file_id, set_at = datetime('now'), set_by = excluded.set_by` + ), + listStickers: db.query<{ event: string; file_id: string }, []>("SELECT event, file_id FROM stickers ORDER BY event"), +}; + +function seedIfEmpty() { + if ((Q.countUsers.get()?.n ?? 0) > 0) return; + for (const pair of ADMIN_BOOTSTRAP.split(",").map((s) => s.trim()).filter(Boolean)) { + const [idStr, ...rest] = pair.split(":"); + const id = Number.parseInt(idStr, 10); + if (!Number.isFinite(id)) continue; + Q.upsertUser.run(id, rest.join(":") || null, "admin", null); + console.log(`seeded admin: ${id} (${rest.join(":") || "—"})`); + } +} +seedIfEmpty(); + +const lookup = (id: number | undefined): User | null => (id === undefined ? null : Q.user.get(id) ?? null); + +// Tokens that mean "the caller". Case-insensitive. Recognised wherever a user +// target is expected (e.g. /promote, /linkmc, /approve, /rmuser). +const SELF_MENTIONS = new Set(["me", "@me", "self", "@self"]); + +/** True if `arg` references the caller (e.g. "me", "@me"). */ +function isSelfMention(arg: string | undefined | null): boolean { + return !!arg && SELF_MENTIONS.has(arg.trim().toLowerCase()); +} + +/** Build a Resolved target for the caller themselves. */ +function selfTarget(ctx: Context): Resolved | null { + const id = ctx.from?.id; + if (id === undefined) return null; + return { id, user: lookup(id), via: "self" }; +} + +// Resolve a target reference: numeric id, "@me"/"me", "@username", or a text_mention entity in the message. +// Returns the telegram_id (and any local user record we have), or null if unknown. +type Resolved = { id: number; user: User | null; via: "id" | "self" | "users" | "seen" | "mention" }; +function resolveTarget(ctx: Context, raw: string): Resolved | null { + const arg = (raw ?? "").trim(); + if (!arg) return null; + + if (isSelfMention(arg)) return selfTarget(ctx); + + // Numeric id + if (/^-?\d+$/.test(arg)) { + const id = Number.parseInt(arg, 10); + return { id, user: lookup(id), via: "id" }; + } + + // @username — try users table, then seen_users, then text_mention entities in this message + if (arg.startsWith("@")) { + const handle = arg.slice(1); + const u = Q.userByUsername.get(handle); + if (u) return { id: u.telegram_id, user: u, via: "users" }; + + const s = Q.seenByUsername.get(handle); + if (s) return { id: s.telegram_id, user: lookup(s.telegram_id), via: "seen" }; + + const entities = ctx.message?.entities ?? []; + for (const e of entities) { + if (e.type === "text_mention" && (e as any).user?.username?.toLowerCase() === handle.toLowerCase()) { + const id = (e as any).user.id as number; + return { id, user: lookup(id), via: "mention" }; + } + } + return null; + } + + return null; +} + +function unresolvedHelp(): string { + return ( + "couldn't resolve that target.\n" + + "use a numeric id, @me, or @username " + + "(usernames only resolve once the bot has seen them message anywhere)" + ); +} + +// ---- docker compose helpers ---- +async function compose(...args: string[]): Promise<{ ok: boolean; out: string }> { + const proc = Bun.spawn( + [ + "docker", "compose", + "--project-directory", COMPOSE_HOST_DIR, + "-p", COMPOSE_PROJECT, + "-f", COMPOSE_FILE, + ...args, + ], + { cwd: MC_DIR, stdout: "pipe", stderr: "pipe" } + ); + const out = await new Response(proc.stdout).text(); + const err = await new Response(proc.stderr).text(); + return { ok: (await proc.exited) === 0, out: (out + err).trim() }; +} + +async function containerRunning(name: string): Promise { + const proc = Bun.spawn(["docker", "inspect", "-f", "{{.State.Running}}", name], { stdout: "pipe", stderr: "pipe" }); + const out = (await new Response(proc.stdout).text()).trim(); + await proc.exited; + return out === "true"; +} + +const serverRunning = () => containerRunning(SERVER_CONTAINER); +const pfRunning = () => containerRunning(PF_CONTAINER); + +async function tailLogs(container: string, n = 30): Promise { + const proc = Bun.spawn(["docker", "logs", "--tail", String(n), container], { stdout: "pipe", stderr: "pipe" }); + const out = (await new Response(proc.stdout).text()) + (await new Response(proc.stderr).text()); + await proc.exited; + return out; +} + +// ---- Stickers ---- +async function maybeSendSticker(ctx: Context, event: string) { + const row = Q.getSticker.get(event); + if (!row) return; + try { + const m = await ctx.replyWithSticker(row.file_id); + Q.insMsg.run(ctx.chat!.id, m.message_id, ctx.from?.id ?? null, "out", "sticker", `[${event}]`, null); + } catch (e) { + console.warn("sticker send failed:", e); + } +} + +// ---- Bot ---- +const bot = new Bot(TOKEN); + +// Capture incoming + outgoing for the audit log; track usernames + seen users. +bot.use(async (ctx, next) => { + const fromId = ctx.from?.id; + const username = ctx.from?.username ?? null; + const fullName = [ctx.from?.first_name, ctx.from?.last_name].filter(Boolean).join(" ") || null; + if (fromId !== undefined) { + const known = lookup(fromId); + if (known) { + // Refresh username if it has changed. + if ((known.username ?? null) !== username) Q.setUsername.run(username, fromId); + } else { + // Track for later @username resolution. + Q.seenUpsert.run(fromId, username, fullName, ctx.chat?.id ?? 0); + } + } + + if (ctx.message) { + const m = ctx.message; + const kind = m.text ? "text" : m.sticker ? "sticker" : m.photo ? "photo" : m.document ? "document" : "other"; + const body = m.text ?? m.sticker?.emoji ?? m.caption ?? null; + Q.insMsg.run(m.chat.id, m.message_id, m.from?.id ?? null, "in", kind, body, m.reply_to_message?.message_id ?? null); + } else if (ctx.callbackQuery) { + Q.insMsg.run( + ctx.chat?.id ?? 0, + ctx.callbackQuery.message?.message_id ?? null, + ctx.from?.id ?? null, + "in", "callback", ctx.callbackQuery.data ?? null, null + ); + } + return next(); +}); + +// Auth gate (after capture so we still log unauthorized attempts). +bot.use(async (ctx, next) => { + // Always allow non-message updates we handle ourselves (joins) — those skip user auth entirely below. + if (ctx.update.my_chat_member || (ctx.message?.new_chat_members?.length)) { + return next(); + } + + const u = lookup(ctx.from?.id); + const isPrivate = ctx.chat?.type === "private"; + const isPending = u?.status === "pending"; + + if (!u || isPending) { + const reason = isPending + ? "pending admin approval — ask an admin to /approve you" + : `not authorized.\nyour id: ${ctx.from?.id ?? "?"}`; + if (ctx.callbackQuery) { + await ctx.answerCallbackQuery({ text: isPending ? "pending approval" : "not authorized", show_alert: true }); + } else if (ctx.message && isPrivate) { + await ctx.reply(reason); + } + return; + } + (ctx as Context & { user: User }).user = u; + return next(); +}); + +// ---- Group lifecycle ---- + +// Bot itself added/removed from a chat. +bot.on("my_chat_member", async (ctx) => { + const upd = ctx.update.my_chat_member; + const status = upd?.new_chat_member?.status; + const chat = upd?.chat; + if (!chat) return; + const wasAdded = status === "member" || status === "administrator"; + const isGroup = chat.type === "group" || chat.type === "supergroup"; + if (!wasAdded || !isGroup) return; + try { + await ctx.api.sendMessage( + chat.id, + `👋 Minecraft control bot here.\n\n` + + `I run a Minecraft server you can manage from this chat. New members must be approved by an admin before they can use my commands.\n\n` + + `Admins:\n` + + `• /approve @username — grant access (or reply to a member's message with /approve)\n` + + `• /pending — see who's waiting\n` + + `• /deny @username — reject\n\n` + + `Tap /menu for the control panel.`, + { parse_mode: "HTML" } + ); + } catch (e) { + console.warn("welcome send failed:", (e as Error).message); + } +}); + +// New users joining a group — file them as pending. +bot.on("message:new_chat_members", async (ctx) => { + const newMembers = ctx.message?.new_chat_members ?? []; + const adders: string[] = []; + for (const m of newMembers) { + if (m.is_bot) continue; + const fullName = [m.first_name, m.last_name].filter(Boolean).join(" ") || null; + Q.upsertUser.run(m.id, fullName, "user", null); + Q.setStatus.run("pending", m.id); + if (m.username) Q.setUsername.run(m.username, m.id); + const handle = m.username + ? `@${m.username}` + : `${escape(m.first_name ?? String(m.id))}`; + adders.push(`${handle} → /approve ${m.username ? `@${m.username}` : m.id}`); + } + if (!adders.length) return; + try { + await ctx.reply( + `Welcome! ${adders.length === 1 ? "This user is" : "These users are"} pending admin approval.\n\n` + + adders.join("\n"), + { parse_mode: "HTML" } + ); + } catch (e) { + console.warn("welcome reply failed:", (e as Error).message); + } +}); + +// Wrap reply/edit so outbound is logged. +async function sendText(ctx: Context, text: string, kb?: InlineKeyboard) { + const m = await ctx.reply(text, { parse_mode: "HTML", reply_markup: kb }); + Q.insMsg.run(m.chat.id, m.message_id, ctx.from?.id ?? null, "out", "text", text.slice(0, 4000), null); + return m; +} + +async function editPanel(ctx: Context, panel: string, text: string, kb: InlineKeyboard) { + const chatId = ctx.chat!.id; + const existing = Q.getPanel.get(chatId, panel); + try { + if (existing) { + await ctx.api.editMessageText(chatId, existing.message_id, text, { + parse_mode: "HTML", reply_markup: kb, + }); + Q.markMsgEdited.run(chatId, existing.message_id); + // Audit successful edits too — otherwise it looks like the bot stopped responding. + Q.insMsg.run(chatId, existing.message_id, ctx.from?.id ?? null, "out", `panel:${panel}:edit`, text.slice(0, 4000), null); + return existing.message_id; + } + } catch (e) { + // Stale message id (deleted), or basic group migrated to supergroup — drop record and re-send. + Q.delPanel.run(chatId, panel); + } + try { + const m = await ctx.reply(text, { parse_mode: "HTML", reply_markup: kb }); + Q.setPanel.run(chatId, panel, m.message_id); + Q.insMsg.run(m.chat.id, m.message_id, ctx.from?.id ?? null, "out", `panel:${panel}`, text.slice(0, 4000), null); + return m.message_id; + } catch (e) { + // Likely "group chat was upgraded to a supergroup" — telegram sends migrate_to_chat_id. + const desc = (e as GrammyError)?.description ?? ""; + const newId = (e as any)?.parameters?.migrate_to_chat_id; + if (newId && /upgraded to a supergroup/i.test(desc)) { + console.log(`migration: ${chatId} → ${newId}, retrying via api.sendMessage`); + Q.delPanel.run(chatId, panel); + const m = await ctx.api.sendMessage(newId, text, { parse_mode: "HTML", reply_markup: kb }); + Q.setPanel.run(newId, panel, m.message_id); + Q.insMsg.run(newId, m.message_id, ctx.from?.id ?? null, "out", `panel:${panel}:migrated`, text.slice(0, 4000), null); + return m.message_id; + } + throw e; + } +} + +// ---- Panel renderers ---- +function fmtTs(): string { + return new Date().toLocaleTimeString("en-GB", { timeZone: "Africa/Johannesburg", hour12: false }); +} + +async function renderMain(ctx: Context) { + const [mc, pf] = await Promise.all([serverRunning(), pfRunning()]); + const text = + `🎮 Minecraft Control\n` + + `\n` + + `server : ${mc ? "✅ up" : "⛔ down"}\n` + + `tunnel : ${pf ? "✅ up" : "⛔ down"}\n` + + `\n` + + `updated ${fmtTs()}`; + const kb = new InlineKeyboard() + .text(mc && pf ? "⏹ Down All" : "▶ Up All", mc && pf ? "act:downall" : "act:upall").row() + .text(mc ? "🟢 Server ▾" : "🟢 Server ▾", "nav:server").text(pf ? "🟡 Tunnel ▾" : "🟡 Tunnel ▾", "nav:tunnel").row() + .text("📜 Logs", "nav:logs").text("👥 Users", "nav:users").row() + .text("🔄 Refresh", "nav:main"); + return editPanel(ctx, "main", text, kb); +} + +async function renderServer(ctx: Context) { + const mc = await serverRunning(); + const text = `🟢 Server\n\nstatus: ${mc ? "✅ up" : "⛔ down"}\nupdated ${fmtTs()}`; + const kb = new InlineKeyboard() + .text("Start", "act:srv:up").text("Stop", "act:srv:down").text("Restart", "act:srv:restart").row() + .text("📜 Logs", "act:logs:srv").text("« Back", "nav:main"); + return editPanel(ctx, "main", text, kb); +} + +async function renderTunnel(ctx: Context) { + const pf = await pfRunning(); + const text = `🟡 Tunnel (autossh)\n\nstatus: ${pf ? "✅ up" : "⛔ down"}\nupdated ${fmtTs()}`; + const kb = new InlineKeyboard() + .text("Start", "act:pf:up").text("Stop", "act:pf:down").row() + .text("📜 Logs", "act:logs:pf").text("« Back", "nav:main"); + return editPanel(ctx, "main", text, kb); +} + +async function renderLogs(ctx: Context, which: "srv" | "pf") { + const c = which === "srv" ? SERVER_CONTAINER : PF_CONTAINER; + const out = trim(await tailLogs(c, 25), 3500) || "(empty)"; + const text = `📜 ${which === "srv" ? "Server" : "Tunnel"} logs\n
${escape(out)}
`; + const kb = new InlineKeyboard() + .text("Server", "act:logs:srv").text("Tunnel", "act:logs:pf").row() + .text("🔄", which === "srv" ? "act:logs:srv" : "act:logs:pf").text("« Back", "nav:main"); + return editPanel(ctx, "main", text, kb); +} + +async function renderUsers(ctx: Context) { + const u = (ctx as any).user as User; + const rows = Q.users.all(); + const lines = rows.map((r) => { + const mc = r.minecraft_name ? ` ⛏ ${escape(r.minecraft_name)}` : ""; + const handle = r.username ? ` @${escape(r.username)}` : ""; + const icon = r.status === "pending" ? "⏳" : (r.role === "admin" ? "👑" : "👤"); + return `${icon} ${r.telegram_id}${handle} ${escape(r.name ?? "—")}${mc}`; + }); + const pendingCount = rows.filter((r) => r.status === "pending").length; + const head = pendingCount ? `👥 Users (${rows.length}, ${pendingCount} pending)` : `👥 Users (${rows.length})`; + const text = `${head}\n\n${lines.join("\n")}`; + const kb = new InlineKeyboard(); + if (u.role === "admin" && pendingCount) kb.text(`⏳ Pending (${pendingCount})`, "info:pending").row(); + if (u.role === "admin") kb.text("➕ How to add", "info:adduser").row(); + kb.text("🔄", "nav:users").text("« Back", "nav:main"); + return editPanel(ctx, "main", text, kb); +} + +async function renderConfirm(ctx: Context, action: string, prompt: string) { + const text = `⚠ Confirm\n\n${prompt}`; + const kb = new InlineKeyboard() + .text("✅ Yes", `confirm:${action}`).text("❌ Cancel", "nav:main"); + return editPanel(ctx, "main", text, kb); +} + +// ---- Action helpers ---- +async function answerToast(ctx: Context, text: string) { + if (ctx.callbackQuery) await ctx.answerCallbackQuery({ text }).catch(() => {}); +} + +async function actUpAll(ctx: Context) { + const r = await compose("up", "-d"); + await answerToast(ctx, r.ok ? "starting…" : "failed"); + await maybeSendSticker(ctx, r.ok ? "up" : "error"); + await renderMain(ctx); +} + +async function actDownAll(ctx: Context) { + const r = await compose("down"); + await answerToast(ctx, r.ok ? "stopped" : "failed"); + await maybeSendSticker(ctx, r.ok ? "down" : "error"); + await renderMain(ctx); +} + +async function actSrv(ctx: Context, op: "up" | "down" | "restart") { + const args = op === "up" ? ["up", "-d", SERVER_SVC] + : op === "down" ? ["stop", SERVER_SVC] + : ["restart", SERVER_SVC]; + const r = await compose(...args); + await answerToast(ctx, r.ok ? `server ${op} ok` : `server ${op} failed`); + await maybeSendSticker(ctx, r.ok ? (op === "down" ? "down" : "up") : "error"); + await renderServer(ctx); +} + +async function actPf(ctx: Context, op: "up" | "down") { + const args = op === "up" ? ["up", "-d", PF_SVC] : ["stop", PF_SVC]; + const r = await compose(...args); + await answerToast(ctx, r.ok ? `tunnel ${op} ok` : `tunnel ${op} failed`); + if (!r.ok) await maybeSendSticker(ctx, "error"); + await renderTunnel(ctx); +} + +// ---- Callback dispatcher ---- +bot.on("callback_query:data", async (ctx) => { + const data = ctx.callbackQuery.data; + try { + if (data === "nav:main") return void await renderMain(ctx); + if (data === "nav:server") return void await renderServer(ctx); + if (data === "nav:tunnel") return void await renderTunnel(ctx); + if (data === "nav:users") return void await renderUsers(ctx); + if (data === "nav:logs") return void await renderLogs(ctx, "srv"); + + if (data === "act:upall") return void await actUpAll(ctx); + if (data === "act:downall") return void await renderConfirm(ctx, "downall", "Stop server and tunnel?"); + if (data === "confirm:downall") return void await actDownAll(ctx); + + if (data === "act:srv:up") return void await actSrv(ctx, "up"); + if (data === "act:srv:down") return void await renderConfirm(ctx, "srv:down", "Stop the minecraft server?"); + if (data === "confirm:srv:down") return void await actSrv(ctx, "down"); + if (data === "act:srv:restart") return void await actSrv(ctx, "restart"); + + if (data === "act:pf:up") return void await actPf(ctx, "up"); + if (data === "act:pf:down") return void await actPf(ctx, "down"); + + if (data === "act:logs:srv") return void await renderLogs(ctx, "srv"); + if (data === "act:logs:pf") return void await renderLogs(ctx, "pf"); + + if (data === "info:adduser") { + await ctx.answerCallbackQuery({ text: "use /adduser @username [name] (or numeric id, or @me)", show_alert: true }); + return; + } + if (data === "info:pending") { + await ctx.answerCallbackQuery({ text: "/pending to list, /approve @user to grant access", show_alert: true }); + return; + } + await ctx.answerCallbackQuery({ text: `unknown: ${data}` }); + } catch (e) { + console.error("cb err:", e); + await ctx.answerCallbackQuery({ text: "error" }).catch(() => {}); + } +}); + +// ---- Slash commands ---- +// Every slash command drops a *fresh* panel at the bottom of the chat. Silent +// in-place edits would otherwise be invisible if the existing panel is buried. +// Callback button presses still edit in place (they're tied to a visible panel). +const dropPanel = (ctx: Context) => Q.delPanel.run(ctx.chat!.id, "main"); +bot.command("start", async (ctx) => { dropPanel(ctx); await renderMain(ctx); }); +bot.command("menu", async (ctx) => { dropPanel(ctx); await renderMain(ctx); }); +bot.command("help", async (ctx) => { + await sendText(ctx, helpText((ctx as any).user.role)); +}); +bot.command("whoami", async (ctx) => { + const u = (ctx as any).user as User; + await sendText( + ctx, + `id: ${u.telegram_id}\n` + + `name: ${escape(u.name ?? "—")}\n` + + `role: ${u.role}\n` + + `minecraft: ${u.minecraft_name ? `${escape(u.minecraft_name)}` : "—"}` + ); +}); + +// Self-service: any authenticated user can set/clear their own minecraft name. +bot.command("setmcname", async (ctx) => { + const u = (ctx as any).user as User; + const arg = (ctx.match as string).trim(); + if (!arg) { + Q.setMcName.run(null, u.telegram_id); + return void await sendText(ctx, "minecraft name cleared"); + } + if (!MC_NAME_RE.test(arg)) { + return void await sendText(ctx, "invalid — minecraft names are 3–16 chars, letters/digits/underscore only"); + } + try { + Q.setMcName.run(arg, u.telegram_id); + } catch (e: any) { + if (String(e?.message ?? "").includes("UNIQUE")) { + return void await sendText(ctx, `${escape(arg)} is already claimed by another user`); + } + throw e; + } + await sendText(ctx, `✅ minecraft name set to ${escape(arg)}`); +}); + +// Lookup by minecraft name. +bot.command("whois", async (ctx) => { + const arg = (ctx.match as string).trim(); + if (!arg) return void await sendText(ctx, "usage: /whois <minecraft_name>"); + const u = Q.userByMc.get(arg); + if (!u) return void await sendText(ctx, `no telegram user linked to ${escape(arg)}`); + await sendText( + ctx, + `${escape(u.minecraft_name!)} →\n` + + `telegram: ${u.telegram_id}\n` + + `name: ${escape(u.name ?? "—")}\n` + + `role: ${u.role}` + ); +}); + +bot.command("status", async (ctx) => { dropPanel(ctx); await renderMain(ctx); }); +bot.command("up", async (ctx) => { dropPanel(ctx); await actUpAll(ctx); }); +bot.command("down", async (ctx) => { dropPanel(ctx); await actDownAll(ctx); }); +bot.command("server_up", async (ctx) => { dropPanel(ctx); await actSrv(ctx, "up"); }); +bot.command("server_down", async (ctx) => { dropPanel(ctx); await actSrv(ctx, "down"); }); +bot.command("pf_up", async (ctx) => { dropPanel(ctx); await actPf(ctx, "up"); }); +bot.command("pf_down", async (ctx) => { dropPanel(ctx); await actPf(ctx, "down"); }); +bot.command("logs", async (ctx) => { dropPanel(ctx); await renderLogs(ctx, "srv"); }); +bot.command("pf_logs", async (ctx) => { dropPanel(ctx); await renderLogs(ctx, "pf"); }); + +// User management +bot.command("users", admin(async (ctx) => { await renderUsers(ctx); })); +bot.command("adduser", admin(async (ctx) => { + const tokens = (ctx.match as string).trim().split(/\s+/); + const target = tokens.shift() ?? ""; + const r = resolveTarget(ctx, target); + if (!r) return void await sendText(ctx, "usage: /adduser <id|@username|@me> [name]\n\n" + unresolvedHelp()); + // Best display name we know: explicit arg > seen_users.name + const seen = target.startsWith("@") ? Q.seenByUsername.get(target.slice(1)) : null; + const displayName = tokens.join(" ") || seen?.name || null; + Q.upsertUser.run(r.id, displayName, "user", ctx.user.telegram_id); + if (target.startsWith("@") && r.via !== "self") Q.setUsername.run(target.slice(1), r.id); + const tag = target.startsWith("@") ? ` (${escape(target)})` : ""; + const named = displayName ? ` — ${escape(displayName)}` : ""; + await sendText(ctx, `✅ added ${r.id}${tag}${named}`); +})); + +bot.command("rmuser", admin(async (ctx) => { + const r = resolveTarget(ctx, (ctx.match as string).trim()); + if (!r) return void await sendText(ctx, "usage: /rmuser <id|@username|@me>"); + if (r.id === ctx.user.telegram_id) return void await sendText(ctx, "refusing to remove yourself"); + if (!r.user) return void await sendText(ctx, "no such user"); + Q.delUser.run(r.id); + await sendText(ctx, `✅ removed ${r.id} (${escape(r.user.name ?? "—")})`); +})); + +bot.command("promote", admin(async (ctx) => { + const r = resolveTarget(ctx, (ctx.match as string).trim()); + if (!r) return void await sendText(ctx, "usage: /promote <id|@username|@me>"); + if (!r.user) return void await sendText(ctx, "no such user — /adduser first"); + Q.upsertUser.run(r.id, r.user.name, "admin", ctx.user.telegram_id); + await sendText(ctx, `👑 ${r.id} is now admin`); + registerCommands(); +})); + +bot.command("demote", admin(async (ctx) => { + const r = resolveTarget(ctx, (ctx.match as string).trim()); + if (!r) return void await sendText(ctx, "usage: /demote <id|@username|@me>"); + if (r.id === ctx.user.telegram_id) return void await sendText(ctx, "refusing to demote yourself"); + if (!r.user) return void await sendText(ctx, "no such user"); + Q.upsertUser.run(r.id, r.user.name, "user", ctx.user.telegram_id); + await sendText(ctx, `👤 ${r.id} is now user`); + bot.api.deleteMyCommands({ scope: { type: "chat", chat_id: r.id } }).catch(() => {}); +})); + +// Approval flow. +function targetFromArgOrReply(ctx: Context): Resolved | null { + const arg = (ctx.match as string).trim(); + if (arg) return resolveTarget(ctx, arg); + const replyFrom = ctx.message?.reply_to_message?.from; + if (replyFrom) return { id: replyFrom.id, user: lookup(replyFrom.id), via: "id" }; + return null; +} + +bot.command("approve", admin(async (ctx) => { + const r = targetFromArgOrReply(ctx); + if (!r) return void await sendText(ctx, "usage: /approve <id|@username|@me> or reply to a member's message"); + const existing = lookup(r.id); + if (!existing) Q.upsertUser.run(r.id, null, "user", ctx.user.telegram_id); + Q.setStatus.run("active", r.id); + await sendText(ctx, `✅ approved ${r.id}${existing?.username ? ` (@${escape(existing.username)})` : ""}`); +})); + +bot.command("deny", admin(async (ctx) => { + const r = targetFromArgOrReply(ctx); + if (!r) return void await sendText(ctx, "usage: /deny <id|@username|@me> or reply"); + if (r.id === ctx.user.telegram_id) return void await sendText(ctx, "refusing to deny yourself"); + Q.delUser.run(r.id); + await sendText(ctx, `❌ denied & removed ${r.id}`); +})); + +bot.command("pending", admin(async (ctx) => { + const rows = Q.pendingUsers.all(); + if (!rows.length) return void await sendText(ctx, "no pending users"); + const lines = rows.map((u) => { + const handle = u.username ? ` @${escape(u.username)}` : ""; + const name = u.name ? ` — ${escape(u.name)}` : ""; + return `• ${u.telegram_id}${handle}${name}`; + }); + await sendText(ctx, `Pending approval (${rows.length})\n\n${lines.join("\n")}`); +})); + +// Admin: set/clear another user's minecraft name. Omit the name to clear. +bot.command("linkmc", admin(async (ctx) => { + const parts = (ctx.match as string).trim().split(/\s+/); + const target = parts[0] ?? ""; + const mcArg = parts[1]; + const r = resolveTarget(ctx, target); + if (!r) return void await sendText(ctx, "usage: /linkmc <id|@username|@me> [minecraft_name]\n(omit name to clear)"); + if (!r.user) return void await sendText(ctx, "no such user — /adduser first"); + if (!mcArg) { + Q.setMcName.run(null, r.id); + return void await sendText(ctx, `cleared minecraft name for ${r.id}`); + } + if (!MC_NAME_RE.test(mcArg)) return void await sendText(ctx, "invalid — 3–16 chars, letters/digits/underscore"); + try { + Q.setMcName.run(mcArg, r.id); + } catch (e: any) { + if (String(e?.message ?? "").includes("UNIQUE")) { + return void await sendText(ctx, `${escape(mcArg)} is already claimed`); + } + throw e; + } + await sendText(ctx, `✅ ${r.id}${escape(mcArg)}`); +})); + +// Stickers — admins reply to a sticker with /setsticker +bot.command("setsticker", admin(async (ctx) => { + const event = (ctx.match as string).trim(); + if (!event) return void await sendText(ctx, "usage: reply to a sticker with /setsticker <event>\nevents: up, down, error, alert"); + const fileId = ctx.message?.reply_to_message?.sticker?.file_id; + if (!fileId) return void await sendText(ctx, "reply to a sticker message"); + Q.setSticker.run(event, fileId, ctx.user.telegram_id); + await sendText(ctx, `✅ sticker for ${escape(event)} saved`); +})); +bot.command("stickers", admin(async (ctx) => { + const rows = Q.listStickers.all(); + if (rows.length === 0) return void await sendText(ctx, "no stickers configured"); + const lines = rows.map((r) => `• ${escape(r.event)}${escape(r.file_id.slice(0, 24))}…`); + await sendText(ctx, lines.join("\n")); +})); +bot.command("teststicker", admin(async (ctx) => { + const event = (ctx.match as string).trim(); + if (!event) return void await sendText(ctx, "usage: /teststicker <event>"); + await maybeSendSticker(ctx, event); +})); + +// History — last N messages in this chat +bot.command("history", admin(async (ctx) => { + const n = Math.min(50, Math.max(5, Number.parseInt((ctx.match as string).trim(), 10) || 20)); + const rows = db.query<{ direction: string; kind: string | null; body: string | null; created_at: string }, [number, number]>( + `SELECT direction, kind, body, created_at FROM messages WHERE chat_id = ? ORDER BY id DESC LIMIT ?` + ).all(ctx.chat!.id, n); + const lines = rows.reverse().map((r) => + `${r.created_at} ${r.direction === "in" ? "→" : "←"} ${escape(r.kind ?? "?")} ${escape((r.body ?? "").slice(0, 80))}` + ); + await sendText(ctx, lines.join("\n") || "(no history)"); +})); + +// ---- helpers ---- +type AdminCtx = Context & { user: User }; +function admin(handler: (ctx: AdminCtx) => Promise) { + return async (ctx: Context) => { + const c = ctx as AdminCtx; + if (c.user.role !== "admin") { + if (ctx.callbackQuery) await ctx.answerCallbackQuery({ text: "admin only", show_alert: true }); + else await sendText(ctx, "admin only."); + return; + } + return handler(c); + }; +} + +function helpText(role: Role): string { + const lines = [ + "Minecraft control bot", + "", + "Tap /menu for the panel, or:", + "/up · /down — start / stop both", + "/server_up · /server_down", + "/pf_up · /pf_down", + "/logs · /pf_logs", + "/status — show panel", + "/whoami", + "/setmcname <name> — link your Minecraft username", + "/whois <mc_name> — find a Telegram user by Minecraft name", + ]; + if (role === "admin") { + lines.push( + "", + "Admin:", + "/approve · /deny · /pending — manage approvals", + "/users, /adduser <id|@user> [name], /rmuser <target>", + "/promote <target>, /demote <target>", + "/linkmc <target> [mc_name] — set/clear someone's MC name", + "/setsticker <event> (reply to a sticker)", + "/stickers, /teststicker <event>", + "/history [n]", + "", + "Targets accept: numeric id, @username, @me, or a reply.", + ); + } + return lines.join("\n"); +} + +function trim(s: string, max = 3500): string { return s.length <= max ? s : s.slice(0, max) + "\n…(truncated)"; } +function escape(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } + +bot.catch((err) => { + const e = err.error; + if (e instanceof GrammyError) console.error("grammy err:", e.description); + else if (e instanceof HttpError) console.error("network err:", e); + else console.error("bot err:", e); +}); + +async function shutdown(sig: NodeJS.Signals) { + console.log(`got ${sig}, shutting down`); + await bot.stop(); + db.close(); + process.exit(0); +} +process.once("SIGINT", () => shutdown("SIGINT")); +process.once("SIGTERM", () => shutdown("SIGTERM")); + +// ---- Telegram command registration ---- +const USER_COMMANDS = [ + { command: "menu", description: "Show the control panel" }, + { command: "status", description: "Server + tunnel status" }, + { command: "up", description: "Start server and tunnel" }, + { command: "down", description: "Stop server and tunnel" }, + { command: "logs", description: "Last 30 lines of server log" }, + { command: "whoami", description: "Your Telegram + role + MC name" }, + { command: "setmcname", description: "Link your Minecraft username" }, + { command: "whois", description: "Find user by Minecraft name" }, + { command: "help", description: "All commands" }, +]; + +const ADMIN_EXTRA = [ + { command: "approve", description: "Approve a pending member" }, + { command: "deny", description: "Reject a pending member" }, + { command: "pending", description: "List pending members" }, + { command: "users", description: "List users (admin)" }, + { command: "adduser", description: "Add a user (admin)" }, + { command: "rmuser", description: "Remove a user (admin)" }, + { command: "promote", description: "Make user admin" }, + { command: "demote", description: "Demote admin to user" }, + { command: "linkmc", description: "Set/clear someone's MC name" }, + { command: "setsticker", description: "Reply to a sticker to bind it" }, + { command: "stickers", description: "List bound stickers" }, + { command: "teststicker", description: "Preview a sticker by event" }, + { command: "history", description: "Recent message log" }, +]; + +async function registerCommands() { + try { + await bot.api.setMyCommands(USER_COMMANDS); + const adminIds = db.query<{ telegram_id: number }, []>( + "SELECT telegram_id FROM users WHERE role = 'admin'" + ).all(); + const adminCmds = [...USER_COMMANDS, ...ADMIN_EXTRA]; + for (const { telegram_id } of adminIds) { + try { + await bot.api.setMyCommands(adminCmds, { + scope: { type: "chat", chat_id: telegram_id }, + }); + } catch (e) { + // Bot can't reach the admin yet (they haven't /start'd); that's fine. + console.warn(`scoped commands for admin ${telegram_id} skipped:`, (e as Error).message); + } + } + await bot.api.setMyShortDescription("Control your Minecraft server and tunnel from Telegram."); + await bot.api.setMyDescription( + "Tap /menu for the control panel, or use /up · /down · /status. " + + "Admins manage users and stickers; everyone can /setmcname to link their Minecraft handle." + ); + await bot.api.setChatMenuButton({ menu_button: { type: "commands" } }); + console.log(`registered commands (default + ${adminIds.length} admin scopes)`); + } catch (e) { + console.error("registerCommands failed:", e); + } +} + +console.log(`bot starting; users=${Q.countUsers.get()?.n ?? 0}`); +bot.start({ + onStart: async (info) => { + console.log(`bot online as @${info.username}`); + await registerCommands(); + }, +}); diff --git a/bot/docker-compose.yml b/bot/docker-compose.yml new file mode 100644 index 0000000..9c6b1a2 --- /dev/null +++ b/bot/docker-compose.yml @@ -0,0 +1,28 @@ +services: + bot: + build: . + image: minecraft-telegram-bot:latest + container_name: minecraft-bot + restart: unless-stopped + environment: + BOT_TOKEN_FILE: /run/secrets/bot_token + MC_DIR: /mc + DB_PATH: /data/users.db + # Seeded only when DB is empty. Format: "id:Name,id:Name,..." + ADMIN_BOOTSTRAP: "${ADMIN_BOOTSTRAP:-1311866578:Paul}" + # The docker daemon resolves relative bind-mounts in /mc/docker-compose.yml from THIS host path. + COMPOSE_HOST_DIR: "${COMPOSE_HOST_DIR:-/home/paul/containers/minecraft}" + TZ: Africa/Johannesburg + volumes: + # Talk to host docker daemon to manage minecraft + autossh containers. + - /var/run/docker.sock:/var/run/docker.sock + # The minecraft compose project (compose file lives here). + - ..:/mc + # Persistent SQLite users DB. + - ./data:/data + secrets: + - bot_token + +secrets: + bot_token: + file: ../bot.token diff --git a/bot/package.json b/bot/package.json new file mode 100644 index 0000000..798c2e5 --- /dev/null +++ b/bot/package.json @@ -0,0 +1,16 @@ +{ + "name": "minecraft-telegram-bot", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "bun run bot.ts" + }, + "dependencies": { + "grammy": "^1.30.0" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.5.0" + } +} diff --git a/data/server.properties b/data/server.properties index 6694253..b361ca1 100644 --- a/data/server.properties +++ b/data/server.properties @@ -1,5 +1,5 @@ #Minecraft server properties -#Mon Apr 13 16:37:31 UTC 2026 +#Thu May 07 17:17:47 UTC 2026 accepts-transfers=false allow-flight=false broadcast-console-to-ops=true @@ -48,7 +48,7 @@ player-idle-timeout=0 prevent-proxy-connections=false query.port=25565 rate-limit=0 -rcon.password=1539e45bbdd3cee797c79a51 +rcon.password=95a08280a713c687fb8efae0 rcon.port=25575 region-file-compression=deflate require-resource-pack=false diff --git a/data/usercache.json b/data/usercache.json index 544c18d..413c110 100644 --- a/data/usercache.json +++ b/data/usercache.json @@ -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"}] \ No newline at end of file +[{"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"}] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 5eadd4e..1dddfc3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,8 +2,8 @@ services: minecraft: image: itzg/minecraft-server:latest container_name: minecraft - ports: - - "25565:25565" + expose: + - "25565" environment: EULA: "TRUE" ONLINE_MODE: "FALSE" @@ -15,3 +15,29 @@ services: restart: unless-stopped stdin_open: 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