From 811bf582c648d97d8864e477857ad69c71b38e35 Mon Sep 17 00:00:00 2001 From: Paul Kloppers Date: Wed, 13 May 2026 23:36:11 +0200 Subject: [PATCH] feat(redstone): slow-tool indicator + persona-name addressing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that came out of a real "I said hey steve, bot didn't reply" report: 1) Persona-name addressing in groups. botAddressed now ALSO triggers when the message text contains the bot's persona name (regex /\b(redstone|steve)\b/i, case-insensitive, word-boundary). The persona prompt already invites users to call the bot "Redstone" or "Steve" — without this matcher, those messages were silently dropped in groups because Telegram didn't recognise the plain word as a @-mention. Private chats still trigger on every text message (unchanged). 2) Slow-tool indicator for wiki_* calls. minecraft.wiki can lag / rate-limit. New withSlowToolIndicator(chatId, toolName, fn) wraps the tool execution: if the call takes longer than 1500 ms, send a "🔍 looking it up…" placeholder message into the chat and rotate through a 4-phrase loop ("📖 reading the wiki…", "🔎 searching…", "🧭 cross-referencing…") every 900 ms. When the tool resolves, the timer + interval are cleared and the placeholder is deleted, so fast calls (<1.5 s) leave no trace in the chat. Only fires for tools whose name matches /^wiki_/. Wired through the dispatch chain — runRedstoneTurn, runRedstoneTurnViaOpenRouter, and runRedstoneTurnViaGemini all gained a `chatId` parameter so the indicator knows which chat to post into. Co-Authored-By: Claude Opus 4.7 --- bot/bot.ts | 63 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/bot/bot.ts b/bot/bot.ts index 6b5605d..e87bb1c 100644 --- a/bot/bot.ts +++ b/bot/bot.ts @@ -1138,6 +1138,11 @@ function startTypingLoop(ctx: Context, chatId: number): () => void { 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; @@ -1150,6 +1155,7 @@ function botAddressed(ctx: Context, botId: number, botUsername: string): boolean 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; } @@ -1306,6 +1312,47 @@ async function callOpenRouterOnce(messages: ORMessage[]): Promise(chatId: number, toolName: string, fn: () => Promise): Promise { + if (!WIKI_TOOL_RE.test(toolName)) return fn(); + let placeholderId: number | null = null; + let rotateTimer: ReturnType | 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) => ({ @@ -1315,7 +1362,7 @@ function historyToOpenRouter(history: GeminiContent[]): ORMessage[] { } async function runRedstoneTurnViaOpenRouter( - systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role, + systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role, chatId: number, ): Promise { if (!OPENROUTER_API_KEY) return null; const messages: ORMessage[] = [ @@ -1352,7 +1399,7 @@ async function runRedstoneTurnViaOpenRouter( continue; } try { - const result = await tool.handler(args, toolCtx); + const result = await withSlowToolIndicator(chatId, tc.function.name, () => tool.handler(args, toolCtx)); response = { result }; ranTool = true; } catch (e) { @@ -1366,7 +1413,7 @@ async function runRedstoneTurnViaOpenRouter( } async function runRedstoneTurnViaGemini( - systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role, + systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role, chatId: number, ): Promise { const contents: GeminiContent[] = [...history, { role: "user", parts: [{ text: userText }] }]; let ranTool = false; @@ -1398,7 +1445,7 @@ async function runRedstoneTurnViaGemini( response = { error: `tool '${fc.functionCall.name}' requires admin role; the calling user is '${callerRole}'. Inform the user, do not retry.` }; } else { try { - const result = await tool.handler(fc.functionCall.args, toolCtx); + const result = await withSlowToolIndicator(chatId, fc.functionCall.name, () => tool.handler(fc.functionCall.args, toolCtx)); response = { result }; ranTool = true; } catch (e) { @@ -1412,13 +1459,13 @@ async function runRedstoneTurnViaGemini( return { text: null, ranTool }; } -async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role): Promise { +async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role, chatId: number): Promise { // 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); + const orResult = await runRedstoneTurnViaOpenRouter(systemPrompt, history, userText, callerRole, chatId); if (orResult !== null) return orResult; - return runRedstoneTurnViaGemini(systemPrompt, history, userText, callerRole); + return runRedstoneTurnViaGemini(systemPrompt, history, userText, callerRole, chatId); } bot.on("message:text", async (ctx) => { @@ -1457,7 +1504,7 @@ bot.on("message:text", async (ctx) => { 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); + const result = await runRedstoneTurn(sys, history, text, user.role, chatId); reply = result.text; ranTool = result.ranTool; } finally {