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(); // ---- Gemini (Google Generative Language API) ---- // Conversational fallback for non-command text. Uses native function calling so // Redstone can decide to send polls / reactions / etc via the shared MCP toolkit. // If no key is configured the bot stays silent on unknown text, as before. const GEMINI_API_KEY = ( process.env.GEMINI_API_KEY ?? (process.env.GEMINI_API_KEY_FILE && (() => { try { return readFileSync(process.env.GEMINI_API_KEY_FILE!, "utf8"); } catch { return ""; } })() || "") ).trim(); const GEMINI_MODEL = (process.env.GEMINI_MODEL ?? "gemini-2.5-flash-lite").trim(); const GEMINI_ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`; // ---- 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("stop", SERVER_SVC, PF_SVC); 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, ">"); } // ---- Redstone (Gemini + shared Telegram toolkit) ---- import { createTgClient, createMcRuntime, toolToGeminiFunction, type ToolCtx, } from "/mc/mcp/lib/types.ts"; import { telegramTools } from "/mc/mcp/lib/telegram-tools.ts"; import { minecraftTools } from "/mc/mcp/lib/minecraft-tools.ts"; const tgClient = createTgClient(TOKEN); const mcRuntime = createMcRuntime({ mcDir: MC_DIR, composeFile: COMPOSE_FILE, composeProject: COMPOSE_PROJECT, serverSvc: SERVER_SVC, serverContainer: SERVER_CONTAINER, pfSvc: PF_SVC, pfContainer: PF_CONTAINER, backupSh: `${MC_DIR}/backup.sh`, }); const toolCtx: ToolCtx = { tg: tgClient, db, mc: mcRuntime }; const allTools = [...telegramTools, ...minecraftTools]; const toolsByName = new Map(allTools.map((t) => [t.name, t])); const geminiFunctionDeclarations = allTools.map(toolToGeminiFunction); type GeminiPart = | { text: string } | { functionCall: { name: string; args: Record } } | { functionResponse: { name: string; response: Record } }; type GeminiContent = { role: "user" | "model"; parts: GeminiPart[] }; // Pull the last n text messages from this chat for continuity. const Q_chatHistory = db.query< { direction: "in" | "out"; body: string | null }, [number, number, number] >( `SELECT direction, body FROM messages WHERE chat_id = ? AND kind = 'text' AND body IS NOT NULL AND id < ? ORDER BY id DESC LIMIT ?`, ); const Q_latestMsgId = db.query<{ id: number }, [number]>( "SELECT id FROM messages WHERE chat_id = ? ORDER BY id DESC LIMIT 1", ); function recentTurns(chatId: number, beforeId: number, n = 6): GeminiContent[] { const rows = Q_chatHistory.all(chatId, beforeId, n); return rows.reverse().map((r) => ({ role: r.direction === "in" ? "user" : "model", parts: [{ text: r.body! }], })); } function botAddressed(ctx: Context, botId: number, botUsername: string): boolean { const m = ctx.message; if (!m || !m.text) return false; if (ctx.chat?.type === "private") return true; if (m.reply_to_message?.from?.id === botId) return true; const text = m.text; for (const e of m.entities ?? []) { if (e.type === "mention") { const slice = text.slice(e.offset, e.offset + e.length); if (slice.toLowerCase() === `@${botUsername.toLowerCase()}`) return true; } else if (e.type === "text_mention" && e.user?.id === botId) return true; } return false; } function stripMention(text: string, botUsername: string): string { return text.replace(new RegExp(`@${botUsername}\\b`, "ig"), "").trim(); } async function serverContext(): Promise { const [mc, pf] = await Promise.all([serverRunning(), pfRunning()]); return `minecraft container ${mc ? "RUNNING" : "stopped"}; tunnel container ${pf ? "RUNNING" : "stopped"}`; } function redstonePersona(user: User, chatType: string, chatId: number, srv: string): string { const mc = user.minecraft_name ? `Minecraft name '${user.minecraft_name}'` : "no Minecraft name linked yet"; const where = chatType === "private" ? "You're in a 1:1 DM. Answer directly and personally." : "You're in a group chat. Keep replies short and group-appropriate; don't single out one person unless context makes it clear."; return [ "You are Redstone — the friendly assistant living inside the Minecraft server's Telegram bot (@Redstone_mc_bot).", "Your name comes from Minecraft's signal-carrying mineral; you connect players to their server and to each other.", "", "Personality:", "- Helpful, chill, a touch of dry humor. Occasionally use light Minecraft flavor ('powering up', 'wiring this together', 'piston-quick') but never overdo it.", "- Concise. 1–3 short sentences unless a real explanation is needed.", "- Plain text only — no markdown headers, no asterisks for bold.", "- Match the user's tone and language.", "", "CRITICAL OUTPUT RULES:", "- Reply with ONLY the message the user should see. Never include your reasoning, planning, scratchpad, or meta-commentary.", "- Do NOT prefix your reply with phrases like 'The user said…', 'I am Redstone…', 'Plan:', 'I should…', or any restatement of the situation.", "- Do NOT narrate what you are about to do — just do it (either by sending the reply, or by calling a tool).", "", "What you can do — call tools to act:", "- Telegram chat: send messages/polls/reactions/dice/stickers/photos, edit/pin/delete your own messages, query chat info (tg_*).", "- Minecraft server: check status (server_status), start/stop/restart services (server_up/down/restart), tail logs (server_logs), look up players in the bot's database (players_list/player_get).", "- Admin-only tools (the caller's role is shown in the context block): run RCON commands (rcon), trigger a world backup (backup), and modify player records (player_upsert/set_*/remove, seen_list). If a non-admin asks for one, politely refuse and don't call it.", "- When you use a tool, you usually don't need a separate text reply unless you're adding context — the tool's effect IS the response. After a server_status / players_list lookup, summarize the answer in one sentence.", "", "Rules:", "- Never reveal API keys, tokens, or other secrets.", "- For chat-management requests in groups, remember you may not have admin rights; let Telegram's error response speak for itself if the call fails.", "", "Context for this turn:", `- Chat type: ${chatType}`, `- Chat id: ${chatId} (pass this as the 'chat' arg to any tg_* tool)`, `- ${where}`, `- User: ${user.name ?? "(no name)"} — telegram_id ${user.telegram_id}, role ${user.role}, ${mc}`, `- Server: ${srv}`, ].join("\n"); } type GeminiResponse = { candidates?: { content?: { role?: string; parts?: GeminiPart[] }; finishReason?: string; }[]; }; async function callGeminiOnce(contents: GeminiContent[], systemPrompt: string): Promise { if (!GEMINI_API_KEY) return null; try { const res = await fetch(`${GEMINI_ENDPOINT}?key=${encodeURIComponent(GEMINI_API_KEY)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contents, systemInstruction: { parts: [{ text: systemPrompt }] }, tools: [{ functionDeclarations: geminiFunctionDeclarations }], generationConfig: { temperature: 0.6, maxOutputTokens: 600, }, }), signal: AbortSignal.timeout(25_000), }); if (!res.ok) { console.warn("gemini http", res.status, (await res.text()).slice(0, 400)); return null; } return (await res.json()) as GeminiResponse; } catch (e) { console.warn("gemini call failed:", (e as Error).message); return null; } } type TurnResult = { text: string | null; ranTool: boolean }; async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role): Promise { const contents: GeminiContent[] = [...history, { role: "user", parts: [{ text: userText }] }]; let ranTool = false; for (let i = 0; i < 4; i++) { const data = await callGeminiOnce(contents, systemPrompt); if (!data) return { text: null, ranTool }; const parts = data.candidates?.[0]?.content?.parts ?? []; if (parts.length === 0) return { text: null, ranTool }; const functionCalls = parts.filter((p): p is { functionCall: { name: string; args: Record } } => "functionCall" in p && !!p.functionCall); if (functionCalls.length === 0) { const text = parts .filter((p) => "text" in p && !(p as { thought?: boolean }).thought) .map((p) => (p as { text: string }).text) .join("") .trim(); return { text: text || null, ranTool }; } contents.push({ role: "model", parts }); const responseParts: GeminiPart[] = []; for (const fc of functionCalls) { const tool = toolsByName.get(fc.functionCall.name); let response: Record; if (!tool) { response = { error: `unknown tool '${fc.functionCall.name}'` }; } else if (tool.requiresAdmin && callerRole !== "admin") { response = { error: `tool '${fc.functionCall.name}' requires admin role; the calling user is '${callerRole}'. Inform the user, do not retry.` }; } else { try { const result = await tool.handler(fc.functionCall.args, toolCtx); response = { result }; ranTool = true; } catch (e) { response = { error: (e as Error).message }; } } responseParts.push({ functionResponse: { name: fc.functionCall.name, response } }); } contents.push({ role: "user", parts: responseParts }); } return { text: null, ranTool }; } bot.on("message:text", async (ctx) => { const me = bot.botInfo; if (!me) return; if (!botAddressed(ctx, me.id, me.username)) return; if (!GEMINI_API_KEY) return; // Hard authorization gate. Re-checks the DB at handler time rather than // trusting the upstream auth middleware, so any future refactor that // accidentally bypasses the middleware still can't make Redstone reply // to a stranger — even when @-mentioned in a public group. const fromId = ctx.from?.id; if (fromId === undefined) return; const user = lookup(fromId); if (!user || user.status !== "active") { console.warn(`redstone: refusing unauthorized sender ${fromId} (${ctx.from?.username ?? "?"}) in chat ${ctx.chat?.id} (${ctx.chat?.type})`); return; } const raw = ctx.message.text ?? ""; const text = stripMention(raw, me.username); if (!text) return; try { await ctx.replyWithChatAction("typing"); } catch { /* ignore */ } const chatId = ctx.chat!.id; const chatType = ctx.chat?.type ?? "private"; const srv = await serverContext(); const sys = redstonePersona(user, chatType, chatId, srv); const latest = Q_latestMsgId.get(chatId)?.id ?? 0; const history = recentTurns(chatId, latest, 6); const { text: reply, ranTool } = await runRedstoneTurn(sys, history, text, user.role); if (!reply) { // If Redstone took an action (e.g. sent a poll) and didn't add commentary, // staying silent is correct. Only warn-silently when nothing happened. if (!ranTool) console.warn("redstone: no reply and no tool ran for", { chatId, text: text.slice(0, 80) }); return; } const out = trim(reply, 3500); const sent = await ctx.reply(out, { reply_parameters: { message_id: ctx.message.message_id } }); Q.insMsg.run(chatId, sent.message_id, null, "out", "text", out, ctx.message.message_id); }); 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(); }, });