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:
63
bot/bot.ts
63
bot/bot.ts
@@ -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. 1–3 short sentences unless a real explanation is needed.",
|
"- Concise. 1–3 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";
|
||||||
const srv = await serverContext();
|
|
||||||
const sys = redstonePersona(user, chatType, chatId, srv);
|
|
||||||
|
|
||||||
const latest = Q_latestMsgId.get(chatId)?.id ?? 0;
|
// Keep "typing…" visible for the whole AI roundtrip. Telegram's chat action
|
||||||
const history = recentTurns(chatId, latest, 6);
|
// 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);
|
||||||
|
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user