bundle several Redstone QoL fixes: /start+/stop+/clear, auto-collect stickers, emoji→sticker swap

A handful of related Telegram-bot improvements that piled up in one
working session.

Slash menu: /start now brings the whole stack up (was: panel render —
duplicated by /menu) and a new /stop takes it down. /up and /down kept
as undocumented aliases. /clear wipes the current chat's rows from the
messages table — DM-self for users, admin-only in groups — so Redstone's
recentTurns history can be reset on demand.

Sticker auto-collection: every incoming sticker is upserted into a new
sticker_library table (file_id PK, plus emoji, set_name, first_seen,
last_seen, seen_count). No /setsticker needed. The original event-keyed
stickers table is untouched and still drives the bot's own event
triggers (maybeSendSticker("up") etc).

Emoji → sticker swap (replaces the AI-driven sticker tool): instead of
listing 15+ file_ids in the system prompt and exposing tg_send_sticker
as a function call, the prompt now lists just the *emoji palette* the
library knows about. After Redstone replies, pickStickerForReply()
scans the text for the first palette-emoji that appears and fires the
matching sticker as a follow-up via ctx.replyWithSticker — without
inserting a row into the messages table, so the audit log stays
text-only and Redstone's next recentTurns isn't polluted. tg_send_sticker
and tg_stickers_known are filtered out of Redstone's geminiFunctionDeclarations
(still exposed via the MCP server for Claude / external clients).

Reliability: Gemini call timeout bumped from 25s to 45s after a real
timeout in production with the previous (too-large) sticker prompt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 23:52:00 +02:00
parent d22cb73a2c
commit 7cb432195a

View File

@@ -75,6 +75,16 @@ db.exec(`
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.
@@ -172,6 +182,14 @@ const Q = {
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() {
@@ -318,6 +336,12 @@ bot.use(async (ctx, next) => {
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(
@@ -614,7 +638,8 @@ bot.on("callback_query:data", async (ctx) => {
// 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("start", async (ctx) => { dropPanel(ctx); await actUpAll(ctx); });
bot.command("stop", async (ctx) => { dropPanel(ctx); await actDownAll(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));
@@ -677,6 +702,25 @@ bot.command("pf_down", async (ctx) => { dropPanel(ctx); await actPf(ctx, "do
bot.command("logs", async (ctx) => { dropPanel(ctx); await renderLogs(ctx, "srv"); });
bot.command("pf_logs", async (ctx) => { dropPanel(ctx); await renderLogs(ctx, "pf"); });
// 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) => {
@@ -835,11 +879,12 @@ function helpText(role: Role): string {
"<b>Minecraft control bot</b>",
"",
"Tap <b>/menu</b> for the panel, or:",
"<code>/up</code> · <code>/down</code> — start / stop both",
"<code>/start</code> · <code>/stop</code> — start / stop server + tunnel (<code>/up</code>, <code>/down</code> still work)",
"<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>/clear</code> — wipe this chat's audit history (admin only in groups)",
"<code>/whoami</code>",
"<code>/setmcname &lt;name&gt;</code> — link your Minecraft username",
"<code>/whois &lt;mc_name&gt;</code> — find a Telegram user by Minecraft name",
@@ -887,7 +932,12 @@ const mcRuntime = createMcRuntime({
const toolCtx: ToolCtx = { tg: tgClient, db, mc: mcRuntime };
const allTools = [...telegramTools, ...minecraftTools];
const toolsByName = new Map(allTools.map((t) => [t.name, t]));
const geminiFunctionDeclarations = allTools.map(toolToGeminiFunction);
// 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"]);
const geminiFunctionDeclarations = allTools
.filter((t) => !REDSTONE_HIDDEN_TOOLS.has(t.name))
.map(toolToGeminiFunction);
type GeminiPart =
| { text: string }
@@ -940,11 +990,25 @@ async function serverContext(): Promise<string> {
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 — the friendly assistant living inside the Minecraft server's Telegram bot (@Redstone_mc_bot).",
"Your name comes from Minecraft's signal-carrying mineral; you connect players to their server and to each other.",
@@ -966,6 +1030,14 @@ function redstonePersona(user: User, chatType: string, chatId: number, srv: stri
"- 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.",
"- 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 23 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.",
@@ -1001,7 +1073,7 @@ async function callGeminiOnce(contents: GeminiContent[], systemPrompt: string):
maxOutputTokens: 600,
},
}),
signal: AbortSignal.timeout(25_000),
signal: AbortSignal.timeout(45_000),
});
if (!res.ok) {
console.warn("gemini http", res.status, (await res.text()).slice(0, 400));
@@ -1104,8 +1176,41 @@ bot.on("message:text", async (ctx) => {
const out = trim(reply, 3500);
const sent = await ctx.reply(out, { reply_parameters: { message_id: ctx.message.message_id } });
Q.insMsg.run(chatId, sent.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.
const stickerFileId = pickStickerForReply(out);
if (stickerFileId) {
try {
await ctx.replyWithSticker(stickerFileId);
} catch (e) {
console.warn("sticker swap failed:", (e as Error).message);
}
}
});
// 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);
@@ -1124,11 +1229,12 @@ process.once("SIGTERM", () => shutdown("SIGTERM"));
// ---- Telegram command registration ----
const USER_COMMANDS = [
{ command: "start", description: "Start server and tunnel" },
{ command: "stop", description: "Stop server and tunnel" },
{ 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: "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" },