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; };
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user