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:
63
bot/bot.ts
63
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<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.
|
||||
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<TurnResult | null> {
|
||||
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<TurnResult> {
|
||||
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<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
|
||||
// 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 {
|
||||
|
||||
Reference in New Issue
Block a user