Files
Minecraft-Server/bot/bot.ts

938 lines
40 KiB
TypeScript
Raw Normal View History

2026-05-11 21:51:59 +02:00
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<User, [number]>(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`),
userByMc: db.query<User, [string]>(`SELECT ${USER_COLS} FROM users WHERE minecraft_name = ?`),
userByUsername: db.query<User, [string]>(`SELECT ${USER_COLS} FROM users WHERE username = ?`),
users: db.query<User, []>(`SELECT ${USER_COLS} FROM users ORDER BY role DESC, added_at ASC`),
setUsername: db.query<unknown, [string | null, number]>(
"UPDATE users SET username = ? WHERE telegram_id = ?"
),
setStatus: db.query<unknown, [Status, number]>(
"UPDATE users SET status = ? WHERE telegram_id = ?"
),
pendingUsers: db.query<User, []>(
`SELECT ${USER_COLS} FROM users WHERE status = 'pending' ORDER BY added_at ASC`
),
seenUpsert: db.query<unknown, [number, string | null, string | null, number]>(
`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<unknown, [number, string | null, Role, number | null]>(
`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<unknown, [string | null, number]>(
"UPDATE users SET minecraft_name = ? WHERE telegram_id = ?"
),
delUser: db.query<unknown, [number]>("DELETE FROM users WHERE telegram_id = ?"),
countUsers: db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM users"),
insMsg: db.query<unknown, [number, number | null, number | null, "in" | "out", string | null, string | null, number | null]>(
`INSERT INTO messages (chat_id, message_id, user_id, direction, kind, body, reply_to) VALUES (?, ?, ?, ?, ?, ?, ?)`
),
markMsgEdited: db.query<unknown, [number, number]>(
`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<unknown, [number, string, number]>(
`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<unknown, [number, string]>("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<unknown, [string, string, number]>(
`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, <code>@me</code>, or <code>@username</code> " +
"(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<boolean> {
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<string> {
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,
`<b>👋 Minecraft control bot here.</b>\n\n` +
`I run a Minecraft server you can manage from this chat. New members must be <b>approved by an admin</b> before they can use my commands.\n\n` +
`Admins:\n` +
`• <code>/approve @username</code> — grant access (or reply to a member's message with <code>/approve</code>)\n` +
`• <code>/pending</code> — see who's waiting\n` +
`• <code>/deny @username</code> — reject\n\n` +
`Tap <b>/menu</b> 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}`
: `<a href="tg://user?id=${m.id}">${escape(m.first_name ?? String(m.id))}</a>`;
adders.push(`${handle} → <code>/approve ${m.username ? `@${m.username}` : m.id}</code>`);
}
if (!adders.length) return;
try {
await ctx.reply(
`Welcome! ${adders.length === 1 ? "This user is" : "These users are"} <b>pending admin approval</b>.\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 =
`<b>🎮 Minecraft Control</b>\n` +
`\n` +
`server : ${mc ? "✅ up" : "⛔ down"}\n` +
`tunnel : ${pf ? "✅ up" : "⛔ down"}\n` +
`\n` +
`<i>updated ${fmtTs()}</i>`;
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 = `<b>🟢 Server</b>\n\nstatus: ${mc ? "✅ up" : "⛔ down"}\n<i>updated ${fmtTs()}</i>`;
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 = `<b>🟡 Tunnel (autossh)</b>\n\nstatus: ${pf ? "✅ up" : "⛔ down"}\n<i>updated ${fmtTs()}</i>`;
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 = `<b>📜 ${which === "srv" ? "Server" : "Tunnel"} logs</b>\n<pre>${escape(out)}</pre>`;
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 ? ` ⛏ <code>${escape(r.minecraft_name)}</code>` : "";
const handle = r.username ? ` @${escape(r.username)}` : "";
const icon = r.status === "pending" ? "⏳" : (r.role === "admin" ? "👑" : "👤");
return `${icon} <code>${r.telegram_id}</code>${handle} ${escape(r.name ?? "—")}${mc}`;
});
const pendingCount = rows.filter((r) => r.status === "pending").length;
const head = pendingCount ? `<b>👥 Users (${rows.length}, ${pendingCount} pending)</b>` : `<b>👥 Users (${rows.length})</b>`;
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 = `<b>⚠ Confirm</b>\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 <b>and</b> 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: <code>${u.telegram_id}</code>\n` +
`name: ${escape(u.name ?? "—")}\n` +
`role: ${u.role}\n` +
`minecraft: ${u.minecraft_name ? `<code>${escape(u.minecraft_name)}</code>` : "—"}`
);
});
// 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 316 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, `<code>${escape(arg)}</code> is already claimed by another user`);
}
throw e;
}
await sendText(ctx, `✅ minecraft name set to <code>${escape(arg)}</code>`);
});
// Lookup by minecraft name.
bot.command("whois", async (ctx) => {
const arg = (ctx.match as string).trim();
if (!arg) return void await sendText(ctx, "usage: <code>/whois &lt;minecraft_name&gt;</code>");
const u = Q.userByMc.get(arg);
if (!u) return void await sendText(ctx, `no telegram user linked to <code>${escape(arg)}</code>`);
await sendText(
ctx,
`<code>${escape(u.minecraft_name!)}</code> →\n` +
`telegram: <code>${u.telegram_id}</code>\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: <code>/adduser &lt;id|@username|@me&gt; [name]</code>\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 <code>${r.id}</code>${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: <code>/rmuser &lt;id|@username|@me&gt;</code>");
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 <code>${r.id}</code> (${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: <code>/promote &lt;id|@username|@me&gt;</code>");
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, `👑 <code>${r.id}</code> 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: <code>/demote &lt;id|@username|@me&gt;</code>");
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, `👤 <code>${r.id}</code> 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: <code>/approve &lt;id|@username|@me&gt;</code> 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 <code>${r.id}</code>${existing?.username ? ` (@${escape(existing.username)})` : ""}`);
}));
bot.command("deny", admin(async (ctx) => {
const r = targetFromArgOrReply(ctx);
if (!r) return void await sendText(ctx, "usage: <code>/deny &lt;id|@username|@me&gt;</code> 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 <code>${r.id}</code>`);
}));
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 `• <code>${u.telegram_id}</code>${handle}${name}`;
});
await sendText(ctx, `<b>Pending approval (${rows.length})</b>\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: <code>/linkmc &lt;id|@username|@me&gt; [minecraft_name]</code>\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 <code>${r.id}</code>`);
}
if (!MC_NAME_RE.test(mcArg)) return void await sendText(ctx, "invalid — 316 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, `<code>${escape(mcArg)}</code> is already claimed`);
}
throw e;
}
await sendText(ctx, `✅ <code>${r.id}</code> ↔ <code>${escape(mcArg)}</code>`);
}));
// Stickers — admins reply to a sticker with /setsticker <event>
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 <code>/setsticker &lt;event&gt;</code>\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 <b>${escape(event)}</b> 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) => `• <b>${escape(r.event)}</b> — <code>${escape(r.file_id.slice(0, 24))}…</code>`);
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: <code>/teststicker &lt;event&gt;</code>");
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) =>
`<i>${r.created_at}</i> ${r.direction === "in" ? "→" : "←"} <b>${escape(r.kind ?? "?")}</b> ${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<unknown>) {
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 = [
"<b>Minecraft control bot</b>",
"",
"Tap <b>/menu</b> for the panel, or:",
"<code>/up</code> · <code>/down</code> — start / stop both",
"<code>/server_up</code> · <code>/server_down</code>",
"<code>/pf_up</code> · <code>/pf_down</code>",
"<code>/logs</code> · <code>/pf_logs</code>",
"<code>/status</code> — show panel",
"<code>/whoami</code>",
"<code>/setmcname &lt;name&gt;</code> — link your Minecraft username",
"<code>/whois &lt;mc_name&gt;</code> — find a Telegram user by Minecraft name",
];
if (role === "admin") {
lines.push(
"",
"<i>Admin:</i>",
"<code>/approve</code> · <code>/deny</code> · <code>/pending</code> — manage approvals",
"<code>/users</code>, <code>/adduser &lt;id|@user&gt; [name]</code>, <code>/rmuser &lt;target&gt;</code>",
"<code>/promote &lt;target&gt;</code>, <code>/demote &lt;target&gt;</code>",
"<code>/linkmc &lt;target&gt; [mc_name]</code> — set/clear someone's MC name",
"<code>/setsticker &lt;event&gt;</code> (reply to a sticker)",
"<code>/stickers</code>, <code>/teststicker &lt;event&gt;</code>",
"<code>/history [n]</code>",
"",
"Targets accept: numeric id, <code>@username</code>, <code>@me</code>, 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
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();
},
});