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 {