feat(redstone): slow-tool indicator + persona-name addressing

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 23:36:11 +02:00
parent f05db39903
commit 811bf582c6

View File

@@ -1138,6 +1138,11 @@ function startTypingLoop(ctx: Context, chatId: number): () => void {
return () => { stopped = true; }; 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 { 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;
@@ -1150,6 +1155,7 @@ function botAddressed(ctx: Context, botId: number, botUsername: string): boolean
if (slice.toLowerCase() === `@${botUsername.toLowerCase()}`) return true; if (slice.toLowerCase() === `@${botUsername.toLowerCase()}`) return true;
} else if (e.type === "text_mention" && e.user?.id === botId) return true; } else if (e.type === "text_mention" && e.user?.id === botId) return true;
} }
if (PERSONA_NAME_RE.test(text)) return true;
return false; return false;
} }
@@ -1306,6 +1312,47 @@ async function callOpenRouterOnce(messages: ORMessage[]): Promise<ORResponse | n
} }
} }
// Wiki calls can be slow when minecraft.wiki rate-limits or just lags. For
// tools that match WIKI_TOOL_RE, show a visible "I'm working on it" message
// in the chat with a word rotation — but only if the tool actually takes
// longer than SLOW_TOOL_THRESHOLD_MS. Fast calls stay invisible.
const WIKI_TOOL_RE = /^wiki_/;
const SLOW_TOOL_THRESHOLD_MS = 1500;
const SLOW_TOOL_ROTATE_MS = 900;
const SLOW_TOOL_PHRASES = [
"🔍 looking it up…",
"📖 reading the wiki…",
"🔎 searching…",
"🧭 cross-referencing…",
];
async function withSlowToolIndicator<T>(chatId: number, toolName: string, fn: () => Promise<T>): Promise<T> {
if (!WIKI_TOOL_RE.test(toolName)) return fn();
let placeholderId: number | null = null;
let rotateTimer: ReturnType<typeof setInterval> | 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. // History is plain text-only (see recentTurns); either provider can render it.
function historyToOpenRouter(history: GeminiContent[]): ORMessage[] { function historyToOpenRouter(history: GeminiContent[]): ORMessage[] {
return history.map((c) => ({ return history.map((c) => ({
@@ -1315,7 +1362,7 @@ function historyToOpenRouter(history: GeminiContent[]): ORMessage[] {
} }
async function runRedstoneTurnViaOpenRouter( async function runRedstoneTurnViaOpenRouter(
systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role, systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role, chatId: number,
): Promise<TurnResult | null> { ): Promise<TurnResult | null> {
if (!OPENROUTER_API_KEY) return null; if (!OPENROUTER_API_KEY) return null;
const messages: ORMessage[] = [ const messages: ORMessage[] = [
@@ -1352,7 +1399,7 @@ async function runRedstoneTurnViaOpenRouter(
continue; continue;
} }
try { try {
const result = await tool.handler(args, toolCtx); const result = await withSlowToolIndicator(chatId, tc.function.name, () => tool.handler(args, toolCtx));
response = { result }; response = { result };
ranTool = true; ranTool = true;
} catch (e) { } catch (e) {
@@ -1366,7 +1413,7 @@ async function runRedstoneTurnViaOpenRouter(
} }
async function runRedstoneTurnViaGemini( async function runRedstoneTurnViaGemini(
systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role, systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role, chatId: number,
): Promise<TurnResult> { ): Promise<TurnResult> {
const contents: GeminiContent[] = [...history, { role: "user", parts: [{ text: userText }] }]; const contents: GeminiContent[] = [...history, { role: "user", parts: [{ text: userText }] }];
let ranTool = false; 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.` }; response = { error: `tool '${fc.functionCall.name}' requires admin role; the calling user is '${callerRole}'. Inform the user, do not retry.` };
} else { } else {
try { 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 }; response = { result };
ranTool = true; ranTool = true;
} catch (e) { } catch (e) {
@@ -1412,13 +1459,13 @@ async function runRedstoneTurnViaGemini(
return { text: null, ranTool }; return { text: null, ranTool };
} }
async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role): Promise<TurnResult> { async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role, chatId: number): Promise<TurnResult> {
// OpenRouter `openrouter/free` is the primary path — picks the best free // OpenRouter `openrouter/free` is the primary path — picks the best free
// tool-calling model automatically. Falls through to Gemini on null (no key, // tool-calling model automatically. Falls through to Gemini on null (no key,
// network error, upstream rejected tool use). // 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; if (orResult !== null) return orResult;
return runRedstoneTurnViaGemini(systemPrompt, history, userText, callerRole); return runRedstoneTurnViaGemini(systemPrompt, history, userText, callerRole, chatId);
} }
bot.on("message:text", async (ctx) => { bot.on("message:text", async (ctx) => {
@@ -1457,7 +1504,7 @@ bot.on("message:text", async (ctx) => {
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); const result = await runRedstoneTurn(sys, history, text, user.role, chatId);
reply = result.text; reply = result.text;
ranTool = result.ranTool; ranTool = result.ranTool;
} finally { } finally {