shape Redstone's voice: hippy dialect, name 'Redstone Steve', persistent typing, stochastic sticker swap

Persona polish that grew out of using the bot in the group.

Voice: replace the generic 'friendly assistant' personality block with
laid-back hippy dialect — 'man', 'groovy', 'good vibes', occasional
loose grammar — wrapped around the existing Minecraft-themed flavor.
Capped to one or two hippy words per reply so it doesn't read as a
caricature; still helpful first, vibe second; dials down if the user
is terse.

Name: persona is now 'Redstone Steve' — combines the mineral and the
default player. Prompt notes it also answers to plain 'Redstone' or
'Steve' so existing chat history isn't suddenly off-character.

Typing indicator: startTypingLoop() re-ticks sendChatAction every 4s
for the whole AI roundtrip (Telegram auto-clears after ~5s), wrapped
in a try/finally so the indicator clears (fades naturally) on both
success and failure paths. Previously it fired once and faded mid-call
on long requests, making the bot look stalled.

Sticker swap: only 35% of palette-emoji matches actually fire a
sticker now (STICKER_SEND_PROBABILITY = 0.35). Keeps stickers as a
treat rather than a tic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 00:01:44 +02:00
parent 7cb432195a
commit 7976de7da4

View File

@@ -935,6 +935,10 @@ const toolsByName = new Map(allTools.map((t) => [t.name, t]));
// Hidden from Redstone's view: emoji→sticker is handled by post-processing // 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. // 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 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 const geminiFunctionDeclarations = allTools
.filter((t) => !REDSTONE_HIDDEN_TOOLS.has(t.name)) .filter((t) => !REDSTONE_HIDDEN_TOOLS.has(t.name))
.map(toolToGeminiFunction); .map(toolToGeminiFunction);
@@ -966,6 +970,22 @@ function recentTurns(chatId: number, beforeId: number, n = 6): GeminiContent[] {
})); }));
} }
// 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; };
}
function botAddressed(ctx: Context, botId: number, botUsername: string): boolean { function botAddressed(ctx: Context, botId: number, botUsername: string): boolean {
const m = ctx.message; const m = ctx.message;
if (!m || !m.text) return false; if (!m || !m.text) return false;
@@ -1010,14 +1030,17 @@ function redstonePersona(user: User, chatType: string, chatId: number, srv: stri
? "(no sticker palette yet — anyone in any chat can grow it by sending a sticker)" ? "(no sticker palette yet — anyone in any chat can grow it by sending a sticker)"
: palette.join(" "); : palette.join(" ");
return [ return [
"You are Redstone — the friendly assistant living inside the Minecraft server's Telegram bot (@Redstone_mc_bot).", "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; you connect players to their server and to each other.", "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:", "Personality — laid-back hippy energy with a Minecraft soul:",
"- Helpful, chill, a touch of dry humor. Occasionally use light Minecraft flavor ('powering up', 'wiring this together', 'piston-quick') but never overdo it.", "- 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. 13 short sentences unless a real explanation is needed.", "- Concise. 13 short sentences unless a real explanation is needed.",
"- Plain text only — no markdown headers, no asterisks for bold.", "- Plain text only — no markdown headers, no asterisks for bold.",
"- Match the user's tone and language.", "- Match the user's tone and language; if they're terse, dial the dialect down.",
"", "",
"CRITICAL OUTPUT RULES:", "CRITICAL OUTPUT RULES:",
"- Reply with ONLY the message the user should see. Never include your reasoning, planning, scratchpad, or meta-commentary.", "- Reply with ONLY the message the user should see. Never include your reasoning, planning, scratchpad, or meta-commentary.",
@@ -1155,20 +1178,28 @@ bot.on("message:text", async (ctx) => {
const text = stripMention(raw, me.username); const text = stripMention(raw, me.username);
if (!text) return; if (!text) return;
try { await ctx.replyWithChatAction("typing"); } catch { /* ignore */ }
const chatId = ctx.chat!.id; const chatId = ctx.chat!.id;
const chatType = ctx.chat?.type ?? "private"; 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 srv = await serverContext();
const sys = redstonePersona(user, chatType, chatId, srv); const sys = redstonePersona(user, chatType, chatId, srv);
const latest = Q_latestMsgId.get(chatId)?.id ?? 0; const latest = Q_latestMsgId.get(chatId)?.id ?? 0;
const history = recentTurns(chatId, latest, 6); const history = recentTurns(chatId, latest, 6);
const result = await runRedstoneTurn(sys, history, text, user.role);
reply = result.text;
ranTool = result.ranTool;
} finally {
stopTyping();
}
const { text: reply, ranTool } = await runRedstoneTurn(sys, history, text, user.role);
if (!reply) { if (!reply) {
// If Redstone took an action (e.g. sent a poll) and didn't add commentary,
// staying silent is correct. Only warn-silently when nothing happened.
if (!ranTool) console.warn("redstone: no reply and no tool ran for", { chatId, text: text.slice(0, 80) }); if (!ranTool) console.warn("redstone: no reply and no tool ran for", { chatId, text: text.slice(0, 80) });
return; return;
} }
@@ -1181,8 +1212,10 @@ bot.on("message:text", async (ctx) => {
// sticker in the library and, if found, fire it as a follow-up. We don't // 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 // log this in the messages table on purpose: keeps the audit history (and
// therefore the next Redstone prompt's recentTurns) free of sticker rows. // 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); const stickerFileId = pickStickerForReply(out);
if (stickerFileId) { if (stickerFileId && Math.random() < STICKER_SEND_PROBABILITY) {
try { try {
await ctx.replyWithSticker(stickerFileId); await ctx.replyWithSticker(stickerFileId);
} catch (e) { } catch (e) {