add telegram bot and autossh tunnel
This commit is contained in:
4
bot/.dockerignore
Normal file
4
bot/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
bun.lockb
|
||||
.git
|
||||
*.log
|
||||
24
bot/Dockerfile
Normal file
24
bot/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM oven/bun:1-debian AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN bun install --no-save
|
||||
|
||||
FROM oven/bun:1-debian
|
||||
WORKDIR /app
|
||||
|
||||
# docker CLI (to talk to host docker.sock), ssh + autossh (for portforward)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl gnupg openssh-client autossh procps \
|
||||
&& install -m 0755 -d /etc/apt/keyrings \
|
||||
&& curl -fsSL https://download.docker.com/linux/debian/gpg \
|
||||
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
|
||||
&& chmod a+r /etc/apt/keyrings/docker.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable" \
|
||||
> /etc/apt/sources.list.d/docker.list \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY bot.ts tsconfig.json* ./
|
||||
|
||||
CMD ["bun", "run", "bot.ts"]
|
||||
937
bot/bot.ts
Normal file
937
bot/bot.ts
Normal file
@@ -0,0 +1,937 @@
|
||||
import { Bot, Context, GrammyError, HttpError, InlineKeyboard } from "grammy";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { readFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
// ---- Config ----
|
||||
const TOKEN = (
|
||||
process.env.BOT_TOKEN ??
|
||||
(process.env.BOT_TOKEN_FILE ? readFileSync(process.env.BOT_TOKEN_FILE, "utf8") : "")
|
||||
).trim();
|
||||
if (!TOKEN) throw new Error("BOT_TOKEN or BOT_TOKEN_FILE must be set");
|
||||
|
||||
const MC_DIR = process.env.MC_DIR ?? "/mc";
|
||||
const COMPOSE_FILE = `${MC_DIR}/docker-compose.yml`;
|
||||
// Pin the compose project name so we manage the same containers regardless of where the bot runs.
|
||||
// Without this, compose derives the project from the cwd basename (e.g. "mc" inside the container vs "minecraft" on host).
|
||||
const COMPOSE_PROJECT = process.env.COMPOSE_PROJECT_NAME ?? "minecraft";
|
||||
// CRITICAL: the docker daemon evaluates relative bind-mount paths from this directory, ON THE HOST.
|
||||
// Without this, ./data and ./ssh/spotbot_key resolve to /mc/data and /mc/ssh/... — paths that don't
|
||||
// exist on the host, so docker silently creates empty dirs and the world / ssh-key get lost.
|
||||
const COMPOSE_HOST_DIR = process.env.COMPOSE_HOST_DIR ?? "/home/paul/containers/minecraft";
|
||||
const SERVER_SVC = process.env.SERVER_SERVICE ?? "minecraft";
|
||||
const SERVER_CONTAINER = process.env.SERVER_CONTAINER ?? "minecraft";
|
||||
const PF_SVC = process.env.PORTFORWARD_SERVICE ?? "autossh";
|
||||
const PF_CONTAINER = process.env.PORTFORWARD_CONTAINER ?? "minecraft-autossh";
|
||||
const DB_PATH = process.env.DB_PATH ?? "/data/users.db";
|
||||
const ADMIN_BOOTSTRAP = (process.env.ADMIN_BOOTSTRAP ?? "1311866578:Paul").trim();
|
||||
|
||||
// ---- DB ----
|
||||
mkdirSync(dirname(DB_PATH), { recursive: true });
|
||||
const db = new Database(DB_PATH);
|
||||
db.exec("PRAGMA journal_mode = WAL");
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
telegram_id INTEGER PRIMARY KEY,
|
||||
name TEXT,
|
||||
role TEXT NOT NULL CHECK(role IN ('admin','user')) DEFAULT 'user',
|
||||
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
added_by INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id INTEGER NOT NULL,
|
||||
message_id INTEGER,
|
||||
user_id INTEGER,
|
||||
direction TEXT NOT NULL CHECK(direction IN ('in','out')),
|
||||
kind TEXT,
|
||||
body TEXT,
|
||||
reply_to INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
edited_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_chat ON messages(chat_id, created_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS panels (
|
||||
chat_id INTEGER NOT NULL,
|
||||
panel TEXT NOT NULL,
|
||||
message_id INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY(chat_id, panel)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS stickers (
|
||||
event TEXT PRIMARY KEY,
|
||||
file_id TEXT NOT NULL,
|
||||
set_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
set_by INTEGER
|
||||
);
|
||||
`);
|
||||
|
||||
// Idempotent migrations.
|
||||
function ensureColumn(table: string, col: string, decl: string) {
|
||||
const cols = db.query<{ name: string }, []>(`PRAGMA table_info(${table})`).all();
|
||||
if (!cols.some((c) => c.name === col)) {
|
||||
db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${decl}`);
|
||||
}
|
||||
}
|
||||
ensureColumn("users", "minecraft_name", "TEXT COLLATE NOCASE");
|
||||
ensureColumn("users", "username", "TEXT COLLATE NOCASE");
|
||||
ensureColumn("users", "status", "TEXT NOT NULL DEFAULT 'active'");
|
||||
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_mcname
|
||||
ON users(minecraft_name) WHERE minecraft_name IS NOT NULL`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_users_username
|
||||
ON users(username) WHERE username IS NOT NULL`);
|
||||
|
||||
// Anyone the bot has seen send a message but who isn't (yet) authorized.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS seen_users (
|
||||
telegram_id INTEGER PRIMARY KEY,
|
||||
username TEXT COLLATE NOCASE,
|
||||
name TEXT,
|
||||
first_seen TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_seen TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_chat_id INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_seen_username
|
||||
ON seen_users(username) WHERE username IS NOT NULL;
|
||||
`);
|
||||
|
||||
const MC_NAME_RE = /^[A-Za-z0-9_]{3,16}$/;
|
||||
|
||||
type Role = "admin" | "user";
|
||||
type Status = "active" | "pending";
|
||||
type User = {
|
||||
telegram_id: number; name: string | null; role: Role;
|
||||
minecraft_name: string | null; username: string | null; status: Status;
|
||||
};
|
||||
|
||||
const USER_COLS = "telegram_id, name, role, minecraft_name, username, status";
|
||||
|
||||
const Q = {
|
||||
user: db.query<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 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, `<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 <minecraft_name></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 <id|@username|@me> [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 <id|@username|@me></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 <id|@username|@me></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 <id|@username|@me></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 <id|@username|@me></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 <id|@username|@me></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 <id|@username|@me> [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 — 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, `<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 <event></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 <event></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 <name></code> — link your Minecraft username",
|
||||
"<code>/whois <mc_name></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 <id|@user> [name]</code>, <code>/rmuser <target></code>",
|
||||
"<code>/promote <target></code>, <code>/demote <target></code>",
|
||||
"<code>/linkmc <target> [mc_name]</code> — set/clear someone's MC name",
|
||||
"<code>/setsticker <event></code> (reply to a sticker)",
|
||||
"<code>/stickers</code>, <code>/teststicker <event></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, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
||||
|
||||
bot.catch((err) => {
|
||||
const e = err.error;
|
||||
if (e instanceof GrammyError) console.error("grammy err:", e.description);
|
||||
else if (e instanceof HttpError) console.error("network err:", e);
|
||||
else console.error("bot err:", e);
|
||||
});
|
||||
|
||||
async function shutdown(sig: NodeJS.Signals) {
|
||||
console.log(`got ${sig}, shutting down`);
|
||||
await bot.stop();
|
||||
db.close();
|
||||
process.exit(0);
|
||||
}
|
||||
process.once("SIGINT", () => shutdown("SIGINT"));
|
||||
process.once("SIGTERM", () => shutdown("SIGTERM"));
|
||||
|
||||
// ---- Telegram command registration ----
|
||||
const USER_COMMANDS = [
|
||||
{ command: "menu", description: "Show the control panel" },
|
||||
{ command: "status", description: "Server + tunnel status" },
|
||||
{ command: "up", description: "Start server and tunnel" },
|
||||
{ command: "down", description: "Stop server and tunnel" },
|
||||
{ command: "logs", description: "Last 30 lines of server log" },
|
||||
{ command: "whoami", description: "Your Telegram + role + MC name" },
|
||||
{ command: "setmcname", description: "Link your Minecraft username" },
|
||||
{ command: "whois", description: "Find user by Minecraft name" },
|
||||
{ command: "help", description: "All commands" },
|
||||
];
|
||||
|
||||
const ADMIN_EXTRA = [
|
||||
{ command: "approve", description: "Approve a pending member" },
|
||||
{ command: "deny", description: "Reject a pending member" },
|
||||
{ command: "pending", description: "List pending members" },
|
||||
{ command: "users", description: "List users (admin)" },
|
||||
{ command: "adduser", description: "Add a user (admin)" },
|
||||
{ command: "rmuser", description: "Remove a user (admin)" },
|
||||
{ command: "promote", description: "Make user admin" },
|
||||
{ command: "demote", description: "Demote admin to user" },
|
||||
{ command: "linkmc", description: "Set/clear someone's MC name" },
|
||||
{ command: "setsticker", description: "Reply to a sticker to bind it" },
|
||||
{ command: "stickers", description: "List bound stickers" },
|
||||
{ command: "teststicker", description: "Preview a sticker by event" },
|
||||
{ command: "history", description: "Recent message log" },
|
||||
];
|
||||
|
||||
async function registerCommands() {
|
||||
try {
|
||||
await bot.api.setMyCommands(USER_COMMANDS);
|
||||
const adminIds = db.query<{ telegram_id: number }, []>(
|
||||
"SELECT telegram_id FROM users WHERE role = 'admin'"
|
||||
).all();
|
||||
const adminCmds = [...USER_COMMANDS, ...ADMIN_EXTRA];
|
||||
for (const { telegram_id } of adminIds) {
|
||||
try {
|
||||
await bot.api.setMyCommands(adminCmds, {
|
||||
scope: { type: "chat", chat_id: telegram_id },
|
||||
});
|
||||
} catch (e) {
|
||||
// Bot can't reach the admin yet (they haven't /start'd); that's fine.
|
||||
console.warn(`scoped commands for admin ${telegram_id} skipped:`, (e as Error).message);
|
||||
}
|
||||
}
|
||||
await bot.api.setMyShortDescription("Control your Minecraft server and tunnel from Telegram.");
|
||||
await bot.api.setMyDescription(
|
||||
"Tap /menu for the control panel, or use /up · /down · /status. " +
|
||||
"Admins manage users and stickers; everyone can /setmcname to link their Minecraft handle."
|
||||
);
|
||||
await bot.api.setChatMenuButton({ menu_button: { type: "commands" } });
|
||||
console.log(`registered commands (default + ${adminIds.length} admin scopes)`);
|
||||
} catch (e) {
|
||||
console.error("registerCommands failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`bot starting; users=${Q.countUsers.get()?.n ?? 0}`);
|
||||
bot.start({
|
||||
onStart: async (info) => {
|
||||
console.log(`bot online as @${info.username}`);
|
||||
await registerCommands();
|
||||
},
|
||||
});
|
||||
28
bot/docker-compose.yml
Normal file
28
bot/docker-compose.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
services:
|
||||
bot:
|
||||
build: .
|
||||
image: minecraft-telegram-bot:latest
|
||||
container_name: minecraft-bot
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
BOT_TOKEN_FILE: /run/secrets/bot_token
|
||||
MC_DIR: /mc
|
||||
DB_PATH: /data/users.db
|
||||
# Seeded only when DB is empty. Format: "id:Name,id:Name,..."
|
||||
ADMIN_BOOTSTRAP: "${ADMIN_BOOTSTRAP:-1311866578:Paul}"
|
||||
# The docker daemon resolves relative bind-mounts in /mc/docker-compose.yml from THIS host path.
|
||||
COMPOSE_HOST_DIR: "${COMPOSE_HOST_DIR:-/home/paul/containers/minecraft}"
|
||||
TZ: Africa/Johannesburg
|
||||
volumes:
|
||||
# Talk to host docker daemon to manage minecraft + autossh containers.
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# The minecraft compose project (compose file lives here).
|
||||
- ..:/mc
|
||||
# Persistent SQLite users DB.
|
||||
- ./data:/data
|
||||
secrets:
|
||||
- bot_token
|
||||
|
||||
secrets:
|
||||
bot_token:
|
||||
file: ../bot.token
|
||||
16
bot/package.json
Normal file
16
bot/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "minecraft-telegram-bot",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "bun run bot.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"grammy": "^1.30.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user