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