From 199fef2a5ef1aaf23b3b1ca00df22d39483669d9 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Tue, 20 Jan 2026 01:03:34 -0800 Subject: [PATCH] feat: enhance BlueBubbles group message handling by adding account-specific logging and improving typing signal conditions --- Peekaboo | 1 + extensions/bluebubbles/src/monitor.ts | 12 +++++++++++- src/auto-reply/reply/followup-runner.ts | 1 - src/auto-reply/reply/get-reply-run.ts | 4 ---- src/auto-reply/reply/typing-mode.test.ts | 21 ++++++++++++++------- src/auto-reply/reply/typing-mode.ts | 20 ++++++++++++++++++++ src/web/media.ts | 14 +++++++++++++- 7 files changed, 59 insertions(+), 14 deletions(-) create mode 160000 Peekaboo diff --git a/Peekaboo b/Peekaboo new file mode 160000 index 000000000..5c195f5e4 --- /dev/null +++ b/Peekaboo @@ -0,0 +1 @@ +Subproject commit 5c195f5e46ebfcc953af74fdd05fbc962d05a50c diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index a33520688..d3e87f025 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -43,21 +43,28 @@ function logGroupAllowlistHint(params: { reason: string; entry: string | null; chatName?: string; + accountId?: string; }): void { const logger = params.runtime.log; if (!logger) return; const nameHint = params.chatName ? ` (group name: ${params.chatName})` : ""; + const accountHint = params.accountId + ? ` (or channels.bluebubbles.accounts.${params.accountId}.groupAllowFrom)` + : ""; if (params.entry) { logger( `[bluebubbles] group message blocked (${params.reason}). Allow this group by adding ` + `"${params.entry}" to channels.bluebubbles.groupAllowFrom${nameHint}.`, ); + logger( + `[bluebubbles] add to config: channels.bluebubbles.groupAllowFrom=["${params.entry}"]${accountHint}.`, + ); return; } logger( `[bluebubbles] group message blocked (${params.reason}). Allow groups by setting ` + `channels.bluebubbles.groupPolicy="open" or adding a group id to ` + - `channels.bluebubbles.groupAllowFrom${nameHint}.`, + `channels.bluebubbles.groupAllowFrom${accountHint}${nameHint}.`, ); } @@ -921,6 +928,7 @@ async function processMessage( reason: "groupPolicy=disabled", entry: groupAllowEntry, chatName: groupName, + accountId: account.accountId, }); return; } @@ -932,6 +940,7 @@ async function processMessage( reason: "groupPolicy=allowlist (empty allowlist)", entry: groupAllowEntry, chatName: groupName, + accountId: account.accountId, }); return; } @@ -958,6 +967,7 @@ async function processMessage( reason: "groupPolicy=allowlist (not allowlisted)", entry: groupAllowEntry, chatName: groupName, + accountId: account.accountId, }); return; } diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 60fd84416..0385d71b8 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -116,7 +116,6 @@ export function createFollowupRunner(params: { }; return async (queued: FollowupRun) => { - await typingSignals.signalRunStart(); try { const runId = crypto.randomUUID(); if (queued.run.sessionKey) { diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 28357fa15..fab496081 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -456,10 +456,6 @@ export async function runPreparedReply( }, }; - if (typingSignals.shouldStartImmediately) { - await typingSignals.signalRunStart(); - } - return runReplyAgent({ commandBody: prefixedCommandBody, followupRun, diff --git a/src/auto-reply/reply/typing-mode.test.ts b/src/auto-reply/reply/typing-mode.test.ts index 013e781da..766cbe803 100644 --- a/src/auto-reply/reply/typing-mode.test.ts +++ b/src/auto-reply/reply/typing-mode.test.ts @@ -106,8 +106,9 @@ describe("createTypingSignaler", () => { await signaler.signalMessageStart(); - expect(typing.startTypingLoop).toHaveBeenCalled(); - expect(typing.startTypingOnText).not.toHaveBeenCalled(); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + await signaler.signalTextDelta("hello"); + expect(typing.startTypingOnText).toHaveBeenCalledWith("hello"); }); it("signals on reasoning for thinking mode", async () => { @@ -119,7 +120,8 @@ describe("createTypingSignaler", () => { }); await signaler.signalReasoningDelta(); - + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + await signaler.signalTextDelta("hi"); expect(typing.startTypingLoop).toHaveBeenCalled(); }); @@ -133,11 +135,12 @@ describe("createTypingSignaler", () => { await signaler.signalTextDelta("hi"); + expect(typing.startTypingLoop).toHaveBeenCalled(); expect(typing.refreshTypingTtl).toHaveBeenCalled(); expect(typing.startTypingOnText).not.toHaveBeenCalled(); }); - it("starts typing on tool start when inactive", async () => { + it("does not start typing on tool start before text", async () => { const typing = createMockTypingController(); const signaler = createTypingSignaler({ typing, @@ -147,11 +150,11 @@ describe("createTypingSignaler", () => { await signaler.signalToolStart(); - expect(typing.startTypingLoop).toHaveBeenCalled(); - expect(typing.refreshTypingTtl).toHaveBeenCalled(); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + expect(typing.refreshTypingTtl).not.toHaveBeenCalled(); }); - it("refreshes ttl on tool start when active", async () => { + it("refreshes ttl on tool start when active after text", async () => { const typing = createMockTypingController({ isActive: vi.fn(() => true), }); @@ -161,6 +164,10 @@ describe("createTypingSignaler", () => { isHeartbeat: false, }); + await signaler.signalTextDelta("hello"); + typing.startTypingLoop.mockClear(); + typing.startTypingOnText.mockClear(); + typing.refreshTypingTtl.mockClear(); await signaler.signalToolStart(); expect(typing.refreshTypingTtl).toHaveBeenCalled(); diff --git a/src/auto-reply/reply/typing-mode.ts b/src/auto-reply/reply/typing-mode.ts index 9ba435044..b2e62d8c4 100644 --- a/src/auto-reply/reply/typing-mode.ts +++ b/src/auto-reply/reply/typing-mode.ts @@ -1,4 +1,5 @@ import type { TypingMode } from "../../config/types.js"; +import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; import type { TypingController } from "./typing.js"; export type TypingModeContext = { @@ -46,6 +47,13 @@ export function createTypingSignaler(params: { const shouldStartOnText = mode === "message" || mode === "instant"; const shouldStartOnReasoning = mode === "thinking"; const disabled = isHeartbeat || mode === "never"; + let hasRenderableText = false; + + const isRenderableText = (text?: string): boolean => { + const trimmed = text?.trim(); + if (!trimmed) return false; + return !isSilentReplyText(trimmed, SILENT_REPLY_TOKEN); + }; const signalRunStart = async () => { if (disabled || !shouldStartImmediately) return; @@ -54,28 +62,40 @@ export function createTypingSignaler(params: { const signalMessageStart = async () => { if (disabled || !shouldStartOnMessageStart) return; + if (!hasRenderableText) return; await typing.startTypingLoop(); }; const signalTextDelta = async (text?: string) => { if (disabled) return; + const renderable = isRenderableText(text); + if (renderable) { + hasRenderableText = true; + } else if (text?.trim()) { + return; + } if (shouldStartOnText) { await typing.startTypingOnText(text); return; } if (shouldStartOnReasoning) { + if (!typing.isActive()) { + await typing.startTypingLoop(); + } typing.refreshTypingTtl(); } }; const signalReasoningDelta = async () => { if (disabled || !shouldStartOnReasoning) return; + if (!hasRenderableText) return; await typing.startTypingLoop(); typing.refreshTypingTtl(); }; const signalToolStart = async () => { if (disabled) return; + if (!hasRenderableText) return; if (!typing.isActive()) { await typing.startTypingLoop(); typing.refreshTypingTtl(); diff --git a/src/web/media.ts b/src/web/media.ts index adc24879b..3f9468b63 100644 --- a/src/web/media.ts +++ b/src/web/media.ts @@ -29,6 +29,17 @@ function isHeicSource(opts: { contentType?: string; fileName?: string }): boolea return false; } +function toJpegFileName(fileName?: string): string | undefined { + if (!fileName) return undefined; + const trimmed = fileName.trim(); + if (!trimmed) return fileName; + const parsed = path.parse(trimmed); + if (!parsed.ext || HEIC_EXT_RE.test(parsed.ext)) { + return path.format({ dir: parsed.dir, name: parsed.name || trimmed, ext: ".jpg" }); + } + return path.format({ dir: parsed.dir, name: parsed.name, ext: ".jpg" }); +} + async function loadWebMediaInternal( mediaUrl: string, options: WebMediaOptions = {}, @@ -50,6 +61,7 @@ async function loadWebMediaInternal( ) => { const originalSize = buffer.length; const optimized = await optimizeImageToJpeg(buffer, cap, meta); + const fileName = meta && isHeicSource(meta) ? toJpegFileName(meta.fileName) : meta?.fileName; if (optimized.optimizedSize < originalSize && shouldLogVerbose()) { logVerbose( `Optimized media from ${(originalSize / (1024 * 1024)).toFixed(2)}MB to ${(optimized.optimizedSize / (1024 * 1024)).toFixed(2)}MB (side≤${optimized.resizeSide}px, q=${optimized.quality})`, @@ -67,6 +79,7 @@ async function loadWebMediaInternal( buffer: optimized.buffer, contentType: "image/jpeg", kind: "image" as const, + fileName, }; }; @@ -103,7 +116,6 @@ async function loadWebMediaInternal( contentType: params.contentType, fileName: params.fileName, })), - fileName: params.fileName, }; } if (params.buffer.length > cap) {