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(); }, });