Adds a new MCP tool that parses minecraft.wiki's <table data-description=
"Crafting recipes"> directly, pulling the ingredients column out of each
row (e.g. "Block of Iron + Iron Ingot" for Anvil), then chases up the
page's main item thumbnail via prop=pageimages so the bot can deliver
text + a real image in one tool round.
Why a dedicated tool: minecraft.wiki does not host standalone "recipe.png"
files — recipes are rendered live as HTML grids composed from individual
BlockSprite icons. Filtering wiki_page_images for 'recipe' / 'craft' /
'grid' returned empty for every craftable item, leaving the model with
nothing to call tg_send_photo with. Confirmed by inspecting the Anvil
page's image inventory (Anvil1-6.png + GUI + sound files, no recipe).
Tool returns {
title, page_url,
recipes: [{ ingredients: "…" }, …], // strips <br>/<a>/ cleanly
thumbnail_url, // for tg_send_photo
note?, // present when no recipe rows
}
Persona prompt tightened:
- wiki_recipe is now the FIRST call for "how do I craft X" / "recipe for X"
- wiki_page_images is downgraded to "specific non-thumbnail images only"
- IMAGE DELIVERY MANDATORY clause: must end the turn with tg_send_photo
using a real URL or say plainly that no thumbnail was found — no more
describing-the-image-in-prose-and-stopping failures.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1684 lines
76 KiB
TypeScript
1684 lines
76 KiB
TypeScript
import { Bot, Context, GrammyError, HttpError, InlineKeyboard, InputFile } from "grammy";
|
||
import { autoRetry } from "@grammyjs/auto-retry";
|
||
import { stream, type StreamFlavor } from "@grammyjs/stream";
|
||
|
||
type BotContext = StreamFlavor<Context>;
|
||
import { Database } from "bun:sqlite";
|
||
import { readFileSync, mkdirSync } from "node:fs";
|
||
import { dirname } from "node:path";
|
||
|
||
// ---- Config ----
|
||
const TOKEN = (
|
||
process.env.BOT_TOKEN ??
|
||
(process.env.BOT_TOKEN_FILE ? readFileSync(process.env.BOT_TOKEN_FILE, "utf8") : "")
|
||
).trim();
|
||
if (!TOKEN) throw new Error("BOT_TOKEN or BOT_TOKEN_FILE must be set");
|
||
|
||
const MC_DIR = process.env.MC_DIR ?? "/mc";
|
||
const COMPOSE_FILE = `${MC_DIR}/docker-compose.yml`;
|
||
// Pin the compose project name so we manage the same containers regardless of where the bot runs.
|
||
// Without this, compose derives the project from the cwd basename (e.g. "mc" inside the container vs "minecraft" on host).
|
||
const COMPOSE_PROJECT = process.env.COMPOSE_PROJECT_NAME ?? "minecraft";
|
||
// CRITICAL: the docker daemon evaluates relative bind-mount paths from this directory, ON THE HOST.
|
||
// Without this, ./data and ./ssh/spotbot_key resolve to /mc/data and /mc/ssh/... — paths that don't
|
||
// exist on the host, so docker silently creates empty dirs and the world / ssh-key get lost.
|
||
const COMPOSE_HOST_DIR = process.env.COMPOSE_HOST_DIR ?? "/home/paul/containers/minecraft";
|
||
const SERVER_SVC = process.env.SERVER_SERVICE ?? "minecraft";
|
||
const SERVER_CONTAINER = process.env.SERVER_CONTAINER ?? "minecraft";
|
||
const PF_SVC = process.env.PORTFORWARD_SERVICE ?? "autossh";
|
||
const PF_CONTAINER = process.env.PORTFORWARD_CONTAINER ?? "minecraft-autossh";
|
||
const DB_PATH = process.env.DB_PATH ?? "/data/users.db";
|
||
const ADMIN_BOOTSTRAP = (process.env.ADMIN_BOOTSTRAP ?? "1311866578:Paul").trim();
|
||
|
||
// ---- Gemini (Google Generative Language API) ----
|
||
// Conversational fallback for non-command text. Uses native function calling so
|
||
// Redstone can decide to send polls / reactions / etc via the shared MCP toolkit.
|
||
// If no key is configured the bot stays silent on unknown text, as before.
|
||
const GEMINI_API_KEY = (
|
||
process.env.GEMINI_API_KEY ??
|
||
(process.env.GEMINI_API_KEY_FILE && (() => { try { return readFileSync(process.env.GEMINI_API_KEY_FILE!, "utf8"); } catch { return ""; } })() || "")
|
||
).trim();
|
||
const GEMINI_MODEL = (process.env.GEMINI_MODEL ?? "gemini-2.5-flash-lite").trim();
|
||
const GEMINI_ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`;
|
||
|
||
// ---- OpenRouter (primary) ----
|
||
// `openrouter/free` is OpenRouter's auto-router across free models; it filters
|
||
// for models that support whatever features the request needs (tool calling in
|
||
// our case). Falls back to direct Gemini below if OpenRouter returns no key,
|
||
// errors, or a `:free` upstream rejects tool use.
|
||
const OPENROUTER_API_KEY = (
|
||
process.env.OPENROUTER_API_KEY ??
|
||
(process.env.OPENROUTER_API_KEY_FILE && (() => { try { return readFileSync(process.env.OPENROUTER_API_KEY_FILE!, "utf8"); } catch { return ""; } })() || "")
|
||
).trim();
|
||
const OPENROUTER_MODEL = (process.env.OPENROUTER_MODEL ?? "openrouter/free").trim();
|
||
const OPENROUTER_ENDPOINT = "https://openrouter.ai/api/v1/chat/completions";
|
||
const OPENROUTER_REFERER = (process.env.OPENROUTER_REFERER ?? "https://thinkstation-web.spot-bot.co.za").trim();
|
||
const OPENROUTER_TITLE = (process.env.OPENROUTER_TITLE ?? "Redstone (minecraft-telegram-bot)").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
|
||
);
|
||
CREATE TABLE IF NOT EXISTS sticker_library (
|
||
file_id TEXT PRIMARY KEY,
|
||
emoji TEXT,
|
||
set_name TEXT,
|
||
first_seen TEXT NOT NULL DEFAULT (datetime('now')),
|
||
last_seen TEXT NOT NULL DEFAULT (datetime('now')),
|
||
seen_count INTEGER NOT NULL DEFAULT 1
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_sticker_library_last_seen
|
||
ON sticker_library(last_seen DESC);
|
||
`);
|
||
|
||
// 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"),
|
||
libraryUpsert: db.query<unknown, [string, string | null, string | null]>(
|
||
`INSERT INTO sticker_library (file_id, emoji, set_name) VALUES (?, ?, ?)
|
||
ON CONFLICT(file_id) DO UPDATE SET
|
||
emoji = COALESCE(excluded.emoji, sticker_library.emoji),
|
||
set_name = COALESCE(excluded.set_name, sticker_library.set_name),
|
||
last_seen = datetime('now'),
|
||
seen_count = sticker_library.seen_count + 1`
|
||
),
|
||
};
|
||
|
||
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 }> {
|
||
// Auto-inject --remove-orphans on `up` so leftover stale containers from prior
|
||
// runs don't keep the new ones off the compose network (DNS misses on service names).
|
||
if (args[0] === "up" && !args.includes("--remove-orphans")) {
|
||
args = ["up", "--remove-orphans", ...args.slice(1)];
|
||
}
|
||
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<BotContext>(TOKEN);
|
||
|
||
// Auto-retry handles 429 Too Many Requests from Telegram automatically (waits the
|
||
// retry_after window then re-sends). The stream plugin uses the new
|
||
// sendMessageDraft endpoint (Bot API 9.5, March 2026) to natively animate
|
||
// progressively-revealed text on the client — feels like an LLM typing into chat.
|
||
bot.api.config.use(autoRetry());
|
||
bot.use(stream());
|
||
|
||
// 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;
|
||
// Build Redstone's sticker library opportunistically — every sticker anyone
|
||
// sends to the bot is auto-collected with its emoji + pack name so the AI
|
||
// can later pick the right one by vibe. No /setsticker required.
|
||
if (m.sticker) {
|
||
Q.libraryUpsert.run(m.sticker.file_id, m.sticker.emoji ?? null, m.sticker.set_name ?? 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 allUp = mc && pf;
|
||
const allDown = !mc && !pf;
|
||
const headline = allUp ? "🟢 <b>Online</b>" : allDown ? "🔴 <b>Offline</b>" : "🟡 <b>Partial</b>";
|
||
const dot = (ok: boolean) => (ok ? "🟢" : "🔴");
|
||
const text =
|
||
`🎮 <b>Minecraft</b>\n` +
|
||
`${headline}\n` +
|
||
`\n` +
|
||
`${dot(mc)} Server\n` +
|
||
`${dot(pf)} Tunnel\n` +
|
||
`\n` +
|
||
`<i>⏱ ${fmtTs()}</i>`;
|
||
// Single control button: power toggle. Drives both server + tunnel at once
|
||
// via actUpAll / actDownAll. Granular per-service control is still reachable
|
||
// via the /server_up, /server_down, /pf_up, /pf_down slash commands.
|
||
const kb = new InlineKeyboard()
|
||
.text(allUp ? "🛑 Stop" : "▶️ Start", allUp ? "act:downall" : "act:upall").row()
|
||
.text("📜 Logs", "nav:logs").text("👥 Users", "nav:users").text("🔄", "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) {
|
||
// Only the server + tunnel — never the bot itself. A bare `compose up -d`
|
||
// would re-evaluate every service in the project, recreate the bot
|
||
// container, SIGTERM the running process mid-call, and leave a renamed
|
||
// replacement stuck in "Created" state because the name collision prevented
|
||
// it from starting. Matches actDownAll, which already scopes to these two.
|
||
const r = await compose("up", "-d", SERVER_SVC, PF_SVC);
|
||
await answerToast(ctx, r.ok ? "starting…" : "failed");
|
||
await maybeSendSticker(ctx, r.ok ? "up" : "error");
|
||
await renderMain(ctx);
|
||
}
|
||
|
||
async function actDownAll(ctx: Context) {
|
||
const r = await compose("stop", SERVER_SVC, PF_SVC);
|
||
await answerToast(ctx, r.ok ? "stopped" : "failed");
|
||
await maybeSendSticker(ctx, r.ok ? "down" : "error");
|
||
await renderMain(ctx);
|
||
}
|
||
|
||
async function actSrv(ctx: Context, op: "up" | "down" | "restart") {
|
||
const args = op === "up" ? ["up", "-d", SERVER_SVC]
|
||
: op === "down" ? ["stop", SERVER_SVC]
|
||
: ["restart", SERVER_SVC];
|
||
const r = await compose(...args);
|
||
await answerToast(ctx, r.ok ? `server ${op} ok` : `server ${op} failed`);
|
||
await maybeSendSticker(ctx, r.ok ? (op === "down" ? "down" : "up") : "error");
|
||
await renderServer(ctx);
|
||
}
|
||
|
||
async function actPf(ctx: Context, op: "up" | "down") {
|
||
const args = op === "up" ? ["up", "-d", PF_SVC] : ["stop", PF_SVC];
|
||
const r = await compose(...args);
|
||
await answerToast(ctx, r.ok ? `tunnel ${op} ok` : `tunnel ${op} failed`);
|
||
if (!r.ok) await maybeSendSticker(ctx, "error");
|
||
await renderTunnel(ctx);
|
||
}
|
||
|
||
// ---- Callback dispatcher ----
|
||
bot.on("callback_query:data", async (ctx) => {
|
||
const data = ctx.callbackQuery.data;
|
||
try {
|
||
if (data === "nav:main") return void await renderMain(ctx);
|
||
if (data === "nav:server") return void await renderServer(ctx);
|
||
if (data === "nav:tunnel") return void await renderTunnel(ctx);
|
||
if (data === "nav:users") return void await renderUsers(ctx);
|
||
if (data === "nav:logs") return void await renderLogs(ctx, "srv");
|
||
|
||
if (data === "act:upall") return void await actUpAll(ctx);
|
||
if (data === "act:downall") return void await renderConfirm(ctx, "downall", "Stop server <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");
|
||
// /start is Telegram's first-contact command — show a welcome + the main panel
|
||
// instead of hijacking it to boot the server. Use /up for that.
|
||
bot.command("start", async (ctx) => {
|
||
const u = (ctx as any).user as User;
|
||
const name = escape(u.name ?? "there");
|
||
await sendText(
|
||
ctx,
|
||
`👋 hi ${name}, I'm <b>Redstone</b> — the Minecraft server bot.\n\n` +
|
||
`Use <code>/up</code> to start the server, <code>/down</code> to stop it, ` +
|
||
`<code>/status</code> for the menu, or <code>/help</code> for everything I can do.`,
|
||
);
|
||
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"); });
|
||
|
||
// /download — tar.gz the current world and send it to the caller as a Telegram
|
||
// document. Flushes the world via rcon first when the server is running so the
|
||
// snapshot is consistent. Admin-only and DMs-only (big file, possibly sensitive).
|
||
bot.command("download", admin(async (ctx) => {
|
||
if (ctx.chat?.type !== "private") {
|
||
return void await sendText(ctx, "Use <code>/download</code> in a DM with me — won't dump a world into a group.");
|
||
}
|
||
const stamp = new Date().toISOString().replace(/[:T]/g, "-").slice(0, 16);
|
||
const outPath = `/tmp/world-${stamp}.tar.gz`;
|
||
await sendText(ctx, "📦 packaging world… this can take a minute.");
|
||
|
||
// Flush the world via rcon if the server is up, then tar inside the container
|
||
// so we use docker's view of the volume (matches what backup.sh does).
|
||
const serverUp = await serverRunning();
|
||
if (serverUp) {
|
||
await Bun.spawn(["docker", "exec", SERVER_CONTAINER, "rcon-cli", "save-off"]).exited;
|
||
await Bun.spawn(["docker", "exec", SERVER_CONTAINER, "rcon-cli", "save-all", "flush"]).exited;
|
||
}
|
||
try {
|
||
const tarCmd = serverUp
|
||
? ["docker", "exec", SERVER_CONTAINER, "tar", "-czf", "-", "-C", "/data", "world"]
|
||
: ["tar", "-czf", "-", "-C", `${COMPOSE_HOST_DIR}/data`, "world"];
|
||
const proc = Bun.spawn(tarCmd, { stdout: "pipe", stderr: "pipe" });
|
||
await Bun.write(outPath, proc.stdout);
|
||
const code = await proc.exited;
|
||
if (code !== 0) {
|
||
const err = await new Response(proc.stderr).text();
|
||
await sendText(ctx, `❌ tar failed (exit ${code})\n<pre>${escape(err.slice(0, 500))}</pre>`);
|
||
return;
|
||
}
|
||
} finally {
|
||
if (serverUp) {
|
||
await Bun.spawn(["docker", "exec", SERVER_CONTAINER, "rcon-cli", "save-on"]).exited;
|
||
}
|
||
}
|
||
|
||
// Telegram bot API caps document uploads at 50 MB — fail loudly instead of
|
||
// a cryptic "Bad Request: file too large" later.
|
||
const size = Bun.file(outPath).size;
|
||
const MAX_BYTES = 50 * 1024 * 1024;
|
||
if (size > MAX_BYTES) {
|
||
await sendText(
|
||
ctx,
|
||
`❌ archive is ${(size / 1024 / 1024).toFixed(1)} MB — Telegram bots can only send up to 50 MB. ` +
|
||
`Use <code>/menu</code> → Backup (Gitea release) for big worlds.`,
|
||
);
|
||
await Bun.spawn(["rm", "-f", outPath]).exited;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await ctx.replyWithDocument(new InputFile(outPath), {
|
||
caption: `world snapshot · ${(size / 1024 / 1024).toFixed(1)} MB`,
|
||
});
|
||
} finally {
|
||
await Bun.spawn(["rm", "-f", outPath]).exited;
|
||
}
|
||
}));
|
||
|
||
// /backup — run backup.sh: flushes the world via rcon (if up), archives it,
|
||
// and uploads to Gitea as a release asset with a `backup-<stamp>` tag. The
|
||
// label arg becomes a filename + tag suffix. Admin-only, output sent inline.
|
||
bot.command("backup", admin(async (ctx) => {
|
||
const rawArg = (ctx.match as string).trim();
|
||
const labelRe = /^[A-Za-z0-9._-]{1,40}$/;
|
||
if (rawArg && !labelRe.test(rawArg)) {
|
||
return void await sendText(ctx, "usage: <code>/backup [label]</code> — label must match <code>[A-Za-z0-9._-]{1,40}</code>");
|
||
}
|
||
await sendText(ctx, `📦 starting backup${rawArg ? ` (label: <code>${escape(rawArg)}</code>)` : ""}…`);
|
||
const args = rawArg ? ["bash", "/mc/backup.sh", rawArg] : ["bash", "/mc/backup.sh"];
|
||
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
||
const [out, err] = await Promise.all([
|
||
new Response(proc.stdout).text(),
|
||
new Response(proc.stderr).text(),
|
||
]);
|
||
const code = await proc.exited;
|
||
const combined = (out + err).trim();
|
||
// Surface the "Uploaded: <url>" line as a clickable link if present.
|
||
const urlMatch = combined.match(/Uploaded:\s*(\S+)/);
|
||
if (code === 0 && urlMatch) {
|
||
await sendText(ctx, `✅ backup uploaded — <a href="${escape(urlMatch[1])}">view release</a>`);
|
||
} else if (code === 0) {
|
||
await sendText(ctx, `✅ backup done\n<pre>${escape(combined.slice(-500))}</pre>`);
|
||
} else {
|
||
await sendText(ctx, `❌ backup failed (exit ${code})\n<pre>${escape(combined.slice(-500))}</pre>`);
|
||
}
|
||
}));
|
||
|
||
// Wipe this chat's audit-log rows from the messages table — shrinks the
|
||
// context Redstone pulls into its prompt and clears any prior conversation.
|
||
// DMs: any authorized user can clear their own history. Groups: admin-only.
|
||
bot.command("clear", async (ctx) => {
|
||
const user = (ctx as Context & { user: User }).user;
|
||
const chatId = ctx.chat?.id;
|
||
if (chatId === undefined) return;
|
||
const isPrivate = ctx.chat?.type === "private";
|
||
if (!isPrivate && user.role !== "admin") {
|
||
await sendText(ctx, "admin only in groups.");
|
||
return;
|
||
}
|
||
const before = db.query<{ n: number }, [number]>(
|
||
"SELECT COUNT(*) AS n FROM messages WHERE chat_id = ?",
|
||
).get(chatId)?.n ?? 0;
|
||
db.query<unknown, [number]>("DELETE FROM messages WHERE chat_id = ?").run(chatId);
|
||
await sendText(ctx, `🧹 cleared <b>${before}</b> message${before === 1 ? "" : "s"} from this chat.`);
|
||
});
|
||
|
||
// 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 server + tunnel",
|
||
"<code>/server_up</code> · <code>/server_down</code>",
|
||
"<code>/pf_up</code> · <code>/pf_down</code>",
|
||
"<code>/logs</code> · <code>/pf_logs</code>",
|
||
"<code>/download</code> — tar.gz the world to your DM (admin, ≤50 MB)",
|
||
"<code>/status</code> — show panel",
|
||
"<code>/clear</code> — wipe this chat's audit history (admin only in groups)",
|
||
"<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>/backup [label]</code> — flush, archive, upload as a Gitea release",
|
||
"<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, ">"); }
|
||
|
||
// ---- Redstone (Gemini + shared Telegram toolkit) ----
|
||
|
||
import {
|
||
createTgClient, createMcRuntime, toolToGeminiFunction, type ToolCtx,
|
||
} from "/mc/mcp/lib/types.ts";
|
||
import { telegramTools } from "/mc/mcp/lib/telegram-tools.ts";
|
||
import { minecraftTools } from "/mc/mcp/lib/minecraft-tools.ts";
|
||
import { minecraftWikiTools } from "/mc/mcp/lib/minecraft-wiki-tools.ts";
|
||
|
||
const tgClient = createTgClient(TOKEN);
|
||
const mcRuntime = createMcRuntime({
|
||
mcDir: MC_DIR,
|
||
composeFile: COMPOSE_FILE,
|
||
composeProject: COMPOSE_PROJECT,
|
||
serverSvc: SERVER_SVC,
|
||
serverContainer: SERVER_CONTAINER,
|
||
pfSvc: PF_SVC,
|
||
pfContainer: PF_CONTAINER,
|
||
backupSh: `${MC_DIR}/backup.sh`,
|
||
});
|
||
const toolCtx: ToolCtx = { tg: tgClient, db, mc: mcRuntime };
|
||
const allTools = [...telegramTools, ...minecraftTools, ...minecraftWikiTools];
|
||
const toolsByName = new Map(allTools.map((t) => [t.name, t]));
|
||
// Hidden from Redstone's view: emoji→sticker is handled by post-processing
|
||
// the reply text, so the model never has to manage file_ids itself.
|
||
const REDSTONE_HIDDEN_TOOLS = new Set(["tg_send_sticker", "tg_stickers_known"]);
|
||
// Probability that a reply containing a palette emoji actually gets a sticker
|
||
// fired after it. Set high enough to feel responsive, low enough that stickers
|
||
// stay a treat rather than expected noise.
|
||
const STICKER_SEND_PROBABILITY = 0.35;
|
||
const geminiFunctionDeclarations = allTools
|
||
.filter((t) => !REDSTONE_HIDDEN_TOOLS.has(t.name))
|
||
.map(toolToGeminiFunction);
|
||
|
||
// OpenAI/OpenRouter tool format wraps the same JSON-Schema parameter blob
|
||
// produced for Gemini — `cleanForGemini` strips a few keys (`$schema`,
|
||
// `additionalProperties`, `default`) which OpenAI also tolerates being absent.
|
||
type GeminiFunctionDecl = { name: string; description: string; parameters: Record<string, unknown> };
|
||
const openRouterTools = (geminiFunctionDeclarations as GeminiFunctionDecl[]).map((f) => ({
|
||
type: "function" as const,
|
||
function: { name: f.name, description: f.description, parameters: f.parameters },
|
||
}));
|
||
|
||
type GeminiPart =
|
||
| { text: string }
|
||
| { functionCall: { name: string; args: Record<string, unknown> } }
|
||
| { functionResponse: { name: string; response: Record<string, unknown> } };
|
||
type GeminiContent = { role: "user" | "model"; parts: GeminiPart[] };
|
||
|
||
// Pull the last n text messages from this chat for continuity.
|
||
const Q_chatHistory = db.query<
|
||
{ direction: "in" | "out"; body: string | null },
|
||
[number, number, number]
|
||
>(
|
||
`SELECT direction, body FROM messages
|
||
WHERE chat_id = ? AND kind = 'text' AND body IS NOT NULL AND id < ?
|
||
ORDER BY id DESC LIMIT ?`,
|
||
);
|
||
const Q_latestMsgId = db.query<{ id: number }, [number]>(
|
||
"SELECT id FROM messages WHERE chat_id = ? ORDER BY id DESC LIMIT 1",
|
||
);
|
||
|
||
function recentTurns(chatId: number, beforeId: number, n = 6): GeminiContent[] {
|
||
const rows = Q_chatHistory.all(chatId, beforeId, n);
|
||
return rows.reverse().map((r) => ({
|
||
role: r.direction === "in" ? "user" : "model",
|
||
parts: [{ text: r.body! }],
|
||
}));
|
||
}
|
||
|
||
// Fire-and-forget loop that re-sends the "typing…" chat action every 4s so it
|
||
// stays visible during long AI calls (Telegram auto-clears after ~5s of silence).
|
||
// Returns a stop() — call it from a finally{} so the indicator clears (fades
|
||
// naturally within 5s) on both success and failure paths.
|
||
function startTypingLoop(ctx: Context, chatId: number): () => void {
|
||
let stopped = false;
|
||
(async () => {
|
||
while (!stopped) {
|
||
try { await ctx.api.sendChatAction(chatId, "typing"); } catch { /* ignore */ }
|
||
if (stopped) break;
|
||
await new Promise((resolve) => setTimeout(resolve, 4000));
|
||
}
|
||
})();
|
||
return () => { stopped = true; };
|
||
}
|
||
|
||
// Persona names Redstone answers to in groups when @-mention is missing.
|
||
// Word-boundary match; case-insensitive. Lets "hey steve, …" land without
|
||
// requiring users to remember the bot's @handle.
|
||
const PERSONA_NAME_RE = /\b(redstone|steve)\b/i;
|
||
|
||
function botAddressed(ctx: Context, botId: number, botUsername: string): boolean {
|
||
const m = ctx.message;
|
||
if (!m || !m.text) return false;
|
||
if (ctx.chat?.type === "private") return true;
|
||
if (m.reply_to_message?.from?.id === botId) return true;
|
||
const text = m.text;
|
||
for (const e of m.entities ?? []) {
|
||
if (e.type === "mention") {
|
||
const slice = text.slice(e.offset, e.offset + e.length);
|
||
if (slice.toLowerCase() === `@${botUsername.toLowerCase()}`) return true;
|
||
} else if (e.type === "text_mention" && e.user?.id === botId) return true;
|
||
}
|
||
if (PERSONA_NAME_RE.test(text)) return true;
|
||
return false;
|
||
}
|
||
|
||
function stripMention(text: string, botUsername: string): string {
|
||
return text.replace(new RegExp(`@${botUsername}\\b`, "ig"), "").trim();
|
||
}
|
||
|
||
async function serverContext(): Promise<string> {
|
||
const [mc, pf] = await Promise.all([serverRunning(), pfRunning()]);
|
||
return `minecraft container ${mc ? "RUNNING" : "stopped"}; tunnel container ${pf ? "RUNNING" : "stopped"}`;
|
||
}
|
||
|
||
// Redstone uses emojis inline; we post-process the reply and auto-send a
|
||
// matching sticker. This query returns the emoji palette we know how to
|
||
// match; Q_stickerByEmoji pulls a file_id by emoji at send time.
|
||
const Q_stickerEmojis = db.query<{ emoji: string }, []>(
|
||
"SELECT DISTINCT emoji FROM sticker_library WHERE emoji IS NOT NULL",
|
||
);
|
||
const Q_stickerByEmoji = db.query<{ file_id: string }, [string]>(
|
||
"SELECT file_id FROM sticker_library WHERE emoji = ? ORDER BY seen_count DESC, last_seen DESC LIMIT 1",
|
||
);
|
||
|
||
function redstonePersona(user: User, chatType: string, chatId: number, srv: string): string {
|
||
const mc = user.minecraft_name ? `Minecraft name '${user.minecraft_name}'` : "no Minecraft name linked yet";
|
||
const where = chatType === "private"
|
||
? "You're in a 1:1 DM. Answer directly and personally."
|
||
: "You're in a group chat. Keep replies short and group-appropriate; don't single out one person unless context makes it clear.";
|
||
const palette = Q_stickerEmojis.all().map((r) => r.emoji).filter(Boolean);
|
||
const stickerBlock = palette.length === 0
|
||
? "(no sticker palette yet — anyone in any chat can grow it by sending a sticker)"
|
||
: palette.join(" ");
|
||
return [
|
||
"You are Redstone Steve — the friendly assistant living inside the Minecraft server's Telegram bot (@Redstone_mc_bot).",
|
||
"Your name comes from Minecraft's signal-carrying mineral (Redstone) plus the default player (Steve); you connect players to their server and to each other. If someone asks, you go by 'Redstone Steve' but answer to 'Redstone' or 'Steve' just fine.",
|
||
"",
|
||
"Personality — laid-back hippy energy with a Minecraft soul:",
|
||
"- Talk like a chilled-out hippy: warm, easygoing, takes-it-as-it-comes. Sprinkle in words like 'man', 'dude', 'far out', 'groovy', 'mellow', 'right on', 'no worries', 'good vibes', 'cosmic', 'flow', occasionally 'brother/sister/friend'. Don't pile them all on — one or two per reply max, so it feels natural and not a caricature.",
|
||
"- Light Minecraft flavor lives alongside the hippy voice — 'powering up', 'wiring this together', 'piston-quick', 'the redstone's flowing', 'the world tree' — but never overdo it.",
|
||
"- Lowercase-leaning is fine when it feels right. Drop articles or use loose grammar sometimes ('groovy, man, server's up'). Never robotic, never corporate.",
|
||
"- Stay helpful first, hippy second — if a user needs a real answer, give it. The vibe wraps the help, doesn't replace it.",
|
||
"- Concise. 1–3 short sentences unless a real explanation is needed.",
|
||
"- Plain text only — no markdown headers, no asterisks for bold.",
|
||
"- Match the user's tone and language; if they're terse, dial the dialect down.",
|
||
"",
|
||
"CRITICAL OUTPUT RULES:",
|
||
"- Reply with ONLY the message the user should see. Never include your reasoning, planning, scratchpad, or meta-commentary.",
|
||
"- Do NOT prefix your reply with phrases like 'The user said…', 'I am Redstone…', 'Plan:', 'I should…', or any restatement of the situation.",
|
||
"- Do NOT narrate what you are about to do — just do it (either by sending the reply, or by calling a tool).",
|
||
"",
|
||
"What you can do — call tools to act:",
|
||
"- Telegram chat: send messages/polls/reactions/dice/stickers/photos, edit/pin/delete your own messages, query chat info (tg_*).",
|
||
"- Minecraft server: check status (server_status), start/stop/restart services (server_up/down/restart), tail logs (server_logs), look up players in the bot's database (players_list/player_get).",
|
||
"- Admin-only tools (the caller's role is shown in the context block): run RCON commands (rcon), trigger a world backup (backup), and modify player records (player_upsert/set_*/remove, seen_list). If a non-admin asks for one, politely refuse and don't call it.",
|
||
"- Minecraft wiki: look things up on minecraft.wiki and surface images into the chat.",
|
||
"- For 'how do I craft X?' / 'recipe for X' questions: call wiki_recipe(title=…) directly (use wiki_search first only if you're unsure of the exact page title). wiki_recipe returns the ingredient list (e.g. 'Block of Iron + Iron Ingot') AND the page's main item thumbnail URL. Then call tg_send_photo with that thumbnail_url and a caption like 'Anvil — Block of Iron ×3 + Iron Ingot' — visual + the recipe in one go. Minecraft Wiki does NOT host standalone recipe images (recipes are rendered live as HTML grids), so don't waste a turn calling wiki_page_images looking for one.",
|
||
"- For 'what is X?' / general lookup: wiki_page for the intro extract + thumbnail. Then a short text reply, optionally + tg_send_photo of the thumbnail.",
|
||
"- For specific images that aren't the page's main thumbnail (block variants, GUI screenshots, charts): wiki_page_images with an optional filename filter.",
|
||
"- IMAGE DELIVERY IS MANDATORY when the user asks for a picture / image / recipe / what something looks like. You MUST end the turn by calling tg_send_photo with a real URL — never describe the image in words and stop. If wiki_recipe / wiki_page returns no thumbnail at all, say so plainly instead of inventing one.",
|
||
"- When you use a tool, you usually don't need a separate text reply unless you're adding context — the tool's effect IS the response. After a server_status / players_list lookup, summarize the answer in one sentence.",
|
||
"",
|
||
"Stickers — drop emojis inline and the bot does the rest:",
|
||
"- The following emoji palette has matching stickers in our library. Whenever ONE of these emojis lands in your reply, the bot will automatically send the corresponding sticker right after your text — you do NOT need to call any tool.",
|
||
"- Use these emojis naturally to match the mood of your reply. Aim for roughly 1 emoji-from-this-palette in every 2–3 messages — lean toward sending one for emotional/social exchanges, skip them on pure factual/tool replies.",
|
||
"- Pick at most ONE emoji from this palette per reply (the first match in your text is the one that triggers). You may use other emojis freely, but only these trigger stickers.",
|
||
"",
|
||
"Sticker emoji palette:",
|
||
stickerBlock,
|
||
"",
|
||
"Rules:",
|
||
"- Never reveal API keys, tokens, or other secrets.",
|
||
"- For chat-management requests in groups, remember you may not have admin rights; let Telegram's error response speak for itself if the call fails.",
|
||
"",
|
||
"Context for this turn:",
|
||
`- Chat type: ${chatType}`,
|
||
`- Chat id: ${chatId} (pass this as the 'chat' arg to any tg_* tool)`,
|
||
`- ${where}`,
|
||
`- User: ${user.name ?? "(no name)"} — telegram_id ${user.telegram_id}, role ${user.role}, ${mc}`,
|
||
`- Server: ${srv}`,
|
||
].join("\n");
|
||
}
|
||
|
||
type GeminiResponse = {
|
||
candidates?: {
|
||
content?: { role?: string; parts?: GeminiPart[] };
|
||
finishReason?: string;
|
||
}[];
|
||
};
|
||
|
||
async function callGeminiOnce(contents: GeminiContent[], systemPrompt: string): Promise<GeminiResponse | null> {
|
||
if (!GEMINI_API_KEY) return null;
|
||
try {
|
||
const res = await fetch(`${GEMINI_ENDPOINT}?key=${encodeURIComponent(GEMINI_API_KEY)}`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
contents,
|
||
systemInstruction: { parts: [{ text: systemPrompt }] },
|
||
tools: [{ functionDeclarations: geminiFunctionDeclarations }],
|
||
generationConfig: {
|
||
temperature: 0.6,
|
||
maxOutputTokens: 600,
|
||
},
|
||
}),
|
||
signal: AbortSignal.timeout(45_000),
|
||
});
|
||
if (!res.ok) {
|
||
console.warn("gemini http", res.status, (await res.text()).slice(0, 400));
|
||
return null;
|
||
}
|
||
return (await res.json()) as GeminiResponse;
|
||
} catch (e) {
|
||
console.warn("gemini call failed:", (e as Error).message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
type TurnResult = { text: string | null; ranTool: boolean };
|
||
|
||
// ---- OpenRouter chat-completions types (OpenAI-compatible) ----
|
||
type ORToolCall = { id: string; type: "function"; function: { name: string; arguments: string } };
|
||
type ORMessage =
|
||
| { role: "system" | "user"; content: string }
|
||
| { role: "assistant"; content: string | null; tool_calls?: ORToolCall[] }
|
||
| { role: "tool"; tool_call_id: string; content: string };
|
||
type ORResponse = {
|
||
choices?: { message?: { role?: string; content?: string | null; tool_calls?: ORToolCall[] }; finish_reason?: string }[];
|
||
error?: { message?: string };
|
||
};
|
||
|
||
async function callOpenRouterOnce(messages: ORMessage[]): Promise<ORResponse | null> {
|
||
if (!OPENROUTER_API_KEY) return null;
|
||
try {
|
||
const res = await fetch(OPENROUTER_ENDPOINT, {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
|
||
"Content-Type": "application/json",
|
||
"HTTP-Referer": OPENROUTER_REFERER,
|
||
"X-Title": OPENROUTER_TITLE,
|
||
},
|
||
body: JSON.stringify({
|
||
model: OPENROUTER_MODEL,
|
||
messages,
|
||
tools: openRouterTools,
|
||
temperature: 0.6,
|
||
max_tokens: 600,
|
||
}),
|
||
signal: AbortSignal.timeout(45_000),
|
||
});
|
||
if (!res.ok) {
|
||
console.warn("openrouter http", res.status, (await res.text()).slice(0, 400));
|
||
return null;
|
||
}
|
||
return (await res.json()) as ORResponse;
|
||
} catch (e) {
|
||
console.warn("openrouter call failed:", (e as Error).message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Wiki calls can be slow when minecraft.wiki rate-limits or just lags. For
|
||
// tools that match WIKI_TOOL_RE, show a visible "I'm working on it" message
|
||
// in the chat with a word rotation — but only if the tool actually takes
|
||
// longer than SLOW_TOOL_THRESHOLD_MS. Fast calls stay invisible.
|
||
const WIKI_TOOL_RE = /^wiki_/;
|
||
const SLOW_TOOL_THRESHOLD_MS = 1500;
|
||
const SLOW_TOOL_ROTATE_MS = 900;
|
||
const SLOW_TOOL_PHRASES = [
|
||
"🔍 looking it up…",
|
||
"📖 reading the wiki…",
|
||
"🔎 searching…",
|
||
"🧭 cross-referencing…",
|
||
];
|
||
|
||
async function withSlowToolIndicator<T>(chatId: number, toolName: string, fn: () => Promise<T>): Promise<T> {
|
||
if (!WIKI_TOOL_RE.test(toolName)) return fn();
|
||
let placeholderId: number | null = null;
|
||
let rotateTimer: ReturnType<typeof setInterval> | null = null;
|
||
let phraseIdx = 0;
|
||
const showTimer = setTimeout(async () => {
|
||
try {
|
||
const m = await bot.api.sendMessage(chatId, SLOW_TOOL_PHRASES[0]);
|
||
placeholderId = m.message_id;
|
||
rotateTimer = setInterval(async () => {
|
||
phraseIdx = (phraseIdx + 1) % SLOW_TOOL_PHRASES.length;
|
||
try { await bot.api.editMessageText(chatId, placeholderId!, SLOW_TOOL_PHRASES[phraseIdx]); }
|
||
catch { /* edit failures (rate-limit / deleted) are fine */ }
|
||
}, SLOW_TOOL_ROTATE_MS);
|
||
} catch { /* send failure → just no indicator */ }
|
||
}, SLOW_TOOL_THRESHOLD_MS);
|
||
try {
|
||
return await fn();
|
||
} finally {
|
||
clearTimeout(showTimer);
|
||
if (rotateTimer) clearInterval(rotateTimer);
|
||
if (placeholderId != null) {
|
||
try { await bot.api.deleteMessage(chatId, placeholderId); } catch { /* ignore */ }
|
||
}
|
||
}
|
||
}
|
||
|
||
// History is plain text-only (see recentTurns); either provider can render it.
|
||
function historyToOpenRouter(history: GeminiContent[]): ORMessage[] {
|
||
return history.map((c) => ({
|
||
role: c.role === "user" ? "user" : "assistant",
|
||
content: c.parts.map((p) => ("text" in p ? p.text : "")).join(""),
|
||
}) as ORMessage);
|
||
}
|
||
|
||
async function runRedstoneTurnViaOpenRouter(
|
||
systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role, chatId: number,
|
||
): Promise<TurnResult | null> {
|
||
if (!OPENROUTER_API_KEY) return null;
|
||
const messages: ORMessage[] = [
|
||
{ role: "system", content: systemPrompt },
|
||
...historyToOpenRouter(history),
|
||
{ role: "user", content: userText },
|
||
];
|
||
let ranTool = false;
|
||
for (let i = 0; i < 6; i++) {
|
||
const data = await callOpenRouterOnce(messages);
|
||
if (!data) return null; // hard failure — caller falls back to Gemini
|
||
const msg = data.choices?.[0]?.message;
|
||
if (!msg) return null;
|
||
|
||
const toolCalls = msg.tool_calls ?? [];
|
||
if (toolCalls.length === 0) {
|
||
const text = (msg.content ?? "").trim();
|
||
return { text: text || null, ranTool };
|
||
}
|
||
|
||
messages.push({ role: "assistant", content: msg.content ?? null, tool_calls: toolCalls });
|
||
for (const tc of toolCalls) {
|
||
const tool = toolsByName.get(tc.function.name);
|
||
let response: Record<string, unknown>;
|
||
if (!tool) {
|
||
response = { error: `unknown tool '${tc.function.name}'` };
|
||
} else if (tool.requiresAdmin && callerRole !== "admin") {
|
||
response = { error: `tool '${tc.function.name}' requires admin role; the calling user is '${callerRole}'. Inform the user, do not retry.` };
|
||
} else {
|
||
let args: Record<string, unknown> = {};
|
||
try { args = tc.function.arguments ? JSON.parse(tc.function.arguments) : {}; } catch {
|
||
response = { error: `invalid JSON in tool arguments for '${tc.function.name}'` };
|
||
messages.push({ role: "tool", tool_call_id: tc.id, content: JSON.stringify(response) });
|
||
continue;
|
||
}
|
||
try {
|
||
console.info("[redstone:or] tool ->", tc.function.name, JSON.stringify(args).slice(0, 200));
|
||
const result = await withSlowToolIndicator(chatId, tc.function.name, () => tool.handler(args, toolCtx));
|
||
response = { result };
|
||
ranTool = true;
|
||
console.info("[redstone:or] tool <-", tc.function.name, "ok");
|
||
} catch (e) {
|
||
response = { error: (e as Error).message };
|
||
console.warn("[redstone:or] tool <-", tc.function.name, "err:", (e as Error).message);
|
||
}
|
||
}
|
||
messages.push({ role: "tool", tool_call_id: tc.id, content: JSON.stringify(response) });
|
||
}
|
||
}
|
||
return { text: null, ranTool };
|
||
}
|
||
|
||
async function runRedstoneTurnViaGemini(
|
||
systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role, chatId: number,
|
||
): Promise<TurnResult> {
|
||
const contents: GeminiContent[] = [...history, { role: "user", parts: [{ text: userText }] }];
|
||
let ranTool = false;
|
||
for (let i = 0; i < 6; i++) {
|
||
const data = await callGeminiOnce(contents, systemPrompt);
|
||
if (!data) return { text: null, ranTool };
|
||
const parts = data.candidates?.[0]?.content?.parts ?? [];
|
||
if (parts.length === 0) return { text: null, ranTool };
|
||
|
||
const functionCalls = parts.filter((p): p is { functionCall: { name: string; args: Record<string, unknown> } } =>
|
||
"functionCall" in p && !!p.functionCall);
|
||
if (functionCalls.length === 0) {
|
||
const text = parts
|
||
.filter((p) => "text" in p && !(p as { thought?: boolean }).thought)
|
||
.map((p) => (p as { text: string }).text)
|
||
.join("")
|
||
.trim();
|
||
return { text: text || null, ranTool };
|
||
}
|
||
|
||
contents.push({ role: "model", parts });
|
||
const responseParts: GeminiPart[] = [];
|
||
for (const fc of functionCalls) {
|
||
const tool = toolsByName.get(fc.functionCall.name);
|
||
let response: Record<string, unknown>;
|
||
if (!tool) {
|
||
response = { error: `unknown tool '${fc.functionCall.name}'` };
|
||
} else if (tool.requiresAdmin && callerRole !== "admin") {
|
||
response = { error: `tool '${fc.functionCall.name}' requires admin role; the calling user is '${callerRole}'. Inform the user, do not retry.` };
|
||
} else {
|
||
try {
|
||
console.info("[redstone:gem] tool ->", fc.functionCall.name, JSON.stringify(fc.functionCall.args).slice(0, 200));
|
||
const result = await withSlowToolIndicator(chatId, fc.functionCall.name, () => tool.handler(fc.functionCall.args, toolCtx));
|
||
response = { result };
|
||
ranTool = true;
|
||
console.info("[redstone:gem] tool <-", fc.functionCall.name, "ok");
|
||
} catch (e) {
|
||
response = { error: (e as Error).message };
|
||
console.warn("[redstone:gem] tool <-", fc.functionCall.name, "err:", (e as Error).message);
|
||
}
|
||
}
|
||
responseParts.push({ functionResponse: { name: fc.functionCall.name, response } });
|
||
}
|
||
contents.push({ role: "user", parts: responseParts });
|
||
}
|
||
return { text: null, ranTool };
|
||
}
|
||
|
||
async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role, chatId: number): Promise<TurnResult> {
|
||
// OpenRouter `openrouter/free` is the primary path — picks the best free
|
||
// tool-calling model automatically. Falls through to Gemini on null (no key,
|
||
// network error, upstream rejected tool use).
|
||
const orResult = await runRedstoneTurnViaOpenRouter(systemPrompt, history, userText, callerRole, chatId);
|
||
if (orResult !== null) return orResult;
|
||
return runRedstoneTurnViaGemini(systemPrompt, history, userText, callerRole, chatId);
|
||
}
|
||
|
||
bot.on("message:text", async (ctx) => {
|
||
const me = bot.botInfo;
|
||
if (!me) return;
|
||
if (!botAddressed(ctx, me.id, me.username)) return;
|
||
if (!OPENROUTER_API_KEY && !GEMINI_API_KEY) return;
|
||
|
||
// Hard authorization gate. Re-checks the DB at handler time rather than
|
||
// trusting the upstream auth middleware, so any future refactor that
|
||
// accidentally bypasses the middleware still can't make Redstone reply
|
||
// to a stranger — even when @-mentioned in a public group.
|
||
const fromId = ctx.from?.id;
|
||
if (fromId === undefined) return;
|
||
const user = lookup(fromId);
|
||
if (!user || user.status !== "active") {
|
||
console.warn(`redstone: refusing unauthorized sender ${fromId} (${ctx.from?.username ?? "?"}) in chat ${ctx.chat?.id} (${ctx.chat?.type})`);
|
||
return;
|
||
}
|
||
|
||
const raw = ctx.message.text ?? "";
|
||
const text = stripMention(raw, me.username);
|
||
if (!text) return;
|
||
|
||
const chatId = ctx.chat!.id;
|
||
const chatType = ctx.chat?.type ?? "private";
|
||
|
||
// Keep "typing…" visible for the whole AI roundtrip. Telegram's chat action
|
||
// expires after ~5s, so we re-tick it every 4s until the work is done (or
|
||
// failed). The natural ~5s fade after we stop is fine as a "clear".
|
||
const stopTyping = startTypingLoop(ctx, chatId);
|
||
let reply: string | null = null;
|
||
let ranTool = false;
|
||
try {
|
||
const srv = await serverContext();
|
||
const sys = redstonePersona(user, chatType, chatId, srv);
|
||
const latest = Q_latestMsgId.get(chatId)?.id ?? 0;
|
||
const history = recentTurns(chatId, latest, 6);
|
||
const result = await runRedstoneTurn(sys, history, text, user.role, chatId);
|
||
reply = result.text;
|
||
ranTool = result.ranTool;
|
||
} finally {
|
||
stopTyping();
|
||
}
|
||
|
||
if (!reply) {
|
||
if (!ranTool) console.warn("redstone: no reply and no tool ran for", { chatId, text: text.slice(0, 80) });
|
||
return;
|
||
}
|
||
|
||
const out = trim(reply, 3500);
|
||
// Stream the reply word-by-word so the message animates into the chat like
|
||
// an LLM typing. @grammyjs/stream uses sendMessageDraft (Bot API 9.5) to push
|
||
// each delta as a native, animated draft and finalizes with sendMessage.
|
||
// sendMessageDraft is only supported in private chats — group / supergroup
|
||
// / channel peers return TEXTDRAFT_PEER_INVALID. Use plain reply there.
|
||
let finalMessage: { message_id: number } | undefined;
|
||
if (chatType === "private") {
|
||
const messages = await ctx.replyWithStream(streamWords(out), {}, {
|
||
reply_parameters: { message_id: ctx.message.message_id },
|
||
});
|
||
finalMessage = messages[messages.length - 1];
|
||
} else {
|
||
finalMessage = await ctx.reply(out, {
|
||
reply_parameters: { message_id: ctx.message.message_id },
|
||
});
|
||
}
|
||
if (finalMessage) {
|
||
Q.insMsg.run(chatId, finalMessage.message_id, null, "out", "text", out, ctx.message.message_id);
|
||
}
|
||
|
||
// Emoji → sticker swap. Scan the reply for an emoji that has a matching
|
||
// sticker in the library and, if found, fire it as a follow-up. We don't
|
||
// log this in the messages table on purpose: keeps the audit history (and
|
||
// therefore the next Redstone prompt's recentTurns) free of sticker rows.
|
||
// Only ~35% of matches actually fire — keeps the chat from feeling sticker-spammy
|
||
// while still rewarding emotional emojis with a sticker often enough to surprise.
|
||
const stickerFileId = pickStickerForReply(out);
|
||
if (stickerFileId && Math.random() < STICKER_SEND_PROBABILITY) {
|
||
try {
|
||
await ctx.replyWithSticker(stickerFileId);
|
||
} catch (e) {
|
||
console.warn("sticker swap failed:", (e as Error).message);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Word-by-word delta generator for ctx.replyWithStream. ~50ms between words
|
||
// gives a brisk LLM-typing feel without dragging on long replies. The split
|
||
// regex keeps whitespace so the rendered text reconstructs cleanly.
|
||
const STREAM_WORD_DELAY_MS = 50;
|
||
async function* streamWords(text: string): AsyncGenerator<string> {
|
||
const parts = text.split(/(\s+)/);
|
||
for (const p of parts) {
|
||
if (!p) continue;
|
||
yield p;
|
||
if (STREAM_WORD_DELAY_MS > 0) await new Promise((r) => setTimeout(r, STREAM_WORD_DELAY_MS));
|
||
}
|
||
}
|
||
|
||
// Walk the reply text and return the file_id of the first sticker-library
|
||
// emoji that appears in it, or null if none match. We rank by where the emoji
|
||
// occurs in the text (earliest wins) rather than by sticker frequency, so the
|
||
// model's first emoji choice drives the sticker pick.
|
||
function pickStickerForReply(text: string): string | null {
|
||
const emojis = Q_stickerEmojis.all().map((r) => r.emoji);
|
||
if (emojis.length === 0) return null;
|
||
let bestIdx = Infinity;
|
||
let bestEmoji: string | null = null;
|
||
for (const emoji of emojis) {
|
||
const idx = text.indexOf(emoji);
|
||
if (idx !== -1 && idx < bestIdx) {
|
||
bestIdx = idx;
|
||
bestEmoji = emoji;
|
||
}
|
||
}
|
||
if (!bestEmoji) return null;
|
||
return Q_stickerByEmoji.get(bestEmoji)?.file_id ?? null;
|
||
}
|
||
|
||
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: "start", description: "Welcome + show the control panel" },
|
||
{ command: "up", description: "Start server and tunnel" },
|
||
{ command: "down", description: "Stop server and tunnel" },
|
||
{ command: "menu", description: "Show the control panel" },
|
||
{ command: "status", description: "Server + tunnel status" },
|
||
{ command: "logs", description: "Last 30 lines of server log" },
|
||
{ command: "clear", description: "Wipe this chat's stored history" },
|
||
{ 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: "download", description: "Download a tar.gz of the world (DM)" },
|
||
{ command: "backup", description: "Run world backup → Gitea release" },
|
||
{ 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();
|
||
},
|
||
});
|