diff --git a/bot/bot.ts b/bot/bot.ts index 53c1b3d..7d8fd6e 100644 --- a/bot/bot.ts +++ b/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 // 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); @@ -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 { const m = ctx.message; 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)" : 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.", + "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:", - "- Helpful, chill, a touch of dry humor. Occasionally use light Minecraft flavor ('powering up', 'wiring this together', 'piston-quick') but never overdo it.", + "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.", + "- 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.", @@ -1155,20 +1178,28 @@ bot.on("message:text", async (ctx) => { const text = stripMention(raw, me.username); if (!text) return; - try { await ctx.replyWithChatAction("typing"); } catch { /* ignore */ } - const chatId = ctx.chat!.id; 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; - const history = recentTurns(chatId, latest, 6); + // 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); + reply = result.text; + ranTool = result.ranTool; + } finally { + stopTyping(); + } - const { text: reply, ranTool } = await runRedstoneTurn(sys, history, text, user.role); 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) }); 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 // 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) { + if (stickerFileId && Math.random() < STICKER_SEND_PROBABILITY) { try { await ctx.replyWithSticker(stickerFileId); } catch (e) {