Files
Minecraft-Server/bot/bot.ts

938 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
},
});