From fd36e05bf3bfc1cd86e46fafb2cf41ff17d2f985 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Sat, 24 Jan 2026 12:41:34 -0800 Subject: [PATCH] test: add unit tests for tapback text parsing in BlueBubbles webhook --- extensions/bluebubbles/src/monitor.test.ts | 82 ++++++++++++++ extensions/bluebubbles/src/monitor.ts | 126 +++++++++++++++++++-- 2 files changed, 200 insertions(+), 8 deletions(-) diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 52f4d0791..e0e3aaa7f 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -1303,6 +1303,88 @@ describe("BlueBubbles webhook monitor", () => { }); }); + describe("tapback text parsing", () => { + it("does not rewrite tapback-like text without metadata", async () => { + const account = createMockAccount({ dmPolicy: "open" }); + const config: ClawdbotConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "Loved this idea", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); + const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; + expect(callArgs.ctx.RawBody).toBe("Loved this idea"); + expect(callArgs.ctx.Body).toContain("Loved this idea"); + expect(callArgs.ctx.Body).not.toContain("reacted with"); + }); + + it("parses tapback text with custom emoji when metadata is present", async () => { + const account = createMockAccount({ dmPolicy: "open" }); + const config: ClawdbotConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: 'Reacted 😅 to "nice one"', + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-2", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); + const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; + expect(callArgs.ctx.RawBody).toBe("reacted with 😅"); + expect(callArgs.ctx.Body).toContain("reacted with 😅"); + expect(callArgs.ctx.Body).not.toContain("[[reply_to:"); + }); + }); + describe("ack reactions", () => { it("sends ack reaction when configured", async () => { const { sendBlueBubblesReaction } = await import("./reactions.js"); diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 1fea79dbe..cf183eea1 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -632,6 +632,10 @@ type NormalizedWebhookMessage = { fromMe?: boolean; attachments?: BlueBubblesAttachment[]; balloonBundleId?: string; + associatedMessageGuid?: string; + associatedMessageType?: number; + associatedMessageEmoji?: string; + isTapback?: boolean; participants?: BlueBubblesParticipant[]; replyToId?: string; replyToBody?: string; @@ -685,25 +689,93 @@ const TAPBACK_TEXT_MAP = new Map= 2000 && type < 4000; +} + +function resolveTapbackActionHint(type: number | undefined): "added" | "removed" | undefined { + if (typeof type !== "number" || !Number.isFinite(type)) return undefined; + if (type >= 3000 && type < 4000) return "removed"; + if (type >= 2000 && type < 3000) return "added"; + return undefined; +} + +function resolveTapbackContext(message: NormalizedWebhookMessage): { + emojiHint?: string; + actionHint?: "added" | "removed"; + replyToId?: string; +} | null { + const associatedType = message.associatedMessageType; + const hasTapbackType = isTapbackAssociatedType(associatedType); + const hasTapbackMarker = Boolean(message.associatedMessageEmoji) || Boolean(message.isTapback); + if (!hasTapbackType && !hasTapbackMarker) return null; + const replyToId = message.associatedMessageGuid?.trim() || message.replyToId?.trim() || undefined; + const actionHint = resolveTapbackActionHint(associatedType); + const emojiHint = + message.associatedMessageEmoji?.trim() || REACTION_TYPE_MAP.get(associatedType ?? -1)?.emoji; + return { emojiHint, actionHint, replyToId }; +} + // Detects tapback text patterns like 'Loved "message"' and converts to structured format -function parseTapbackText(text: string): { +function parseTapbackText(params: { + text: string; + emojiHint?: string; + actionHint?: "added" | "removed"; + requireQuoted?: boolean; +}): { emoji: string; action: "added" | "removed"; quotedText: string; } | null { - const trimmed = text.trim(); + const trimmed = params.text.trim(); const lower = trimmed.toLowerCase(); + if (!trimmed) return null; for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) { if (lower.startsWith(pattern)) { // Extract quoted text if present (e.g., 'Loved "hello"' -> "hello") const afterPattern = trimmed.slice(pattern.length).trim(); - // Handle both "quoted" and "quoted" formats - const quoteMatch = afterPattern.match(/^[""](.*)[""]$/s); - const quotedText = quoteMatch ? quoteMatch[1] : afterPattern; + if (params.requireQuoted) { + const strictMatch = afterPattern.match(/^[“"](.+)[”"]$/s); + if (!strictMatch) return null; + return { emoji, action, quotedText: strictMatch[1] }; + } + const quotedText = + extractQuotedTapbackText(afterPattern) ?? extractQuotedTapbackText(trimmed) ?? afterPattern; return { emoji, action, quotedText }; } } + + if (lower.startsWith("reacted")) { + const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint; + if (!emoji) return null; + const quotedText = extractQuotedTapbackText(trimmed); + if (params.requireQuoted && !quotedText) return null; + const fallback = trimmed.slice("reacted".length).trim(); + return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback }; + } + + if (lower.startsWith("removed")) { + const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint; + if (!emoji) return null; + const quotedText = extractQuotedTapbackText(trimmed); + if (params.requireQuoted && !quotedText) return null; + const fallback = trimmed.slice("removed".length).trim(); + return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback }; + } return null; } @@ -854,6 +926,25 @@ function normalizeWebhookMessage(payload: Record): NormalizedWe readString(message, "messageId") ?? undefined; const balloonBundleId = readString(message, "balloonBundleId"); + const associatedMessageGuid = + readString(message, "associatedMessageGuid") ?? + readString(message, "associated_message_guid") ?? + readString(message, "associatedMessageId") ?? + undefined; + const associatedMessageType = + readNumberLike(message, "associatedMessageType") ?? + readNumberLike(message, "associated_message_type"); + const associatedMessageEmoji = + readString(message, "associatedMessageEmoji") ?? + readString(message, "associated_message_emoji") ?? + readString(message, "reactionEmoji") ?? + readString(message, "reaction_emoji") ?? + undefined; + const isTapback = + readBoolean(message, "isTapback") ?? + readBoolean(message, "is_tapback") ?? + readBoolean(message, "tapback") ?? + undefined; const timestampRaw = readNumber(message, "date") ?? @@ -884,6 +975,10 @@ function normalizeWebhookMessage(payload: Record): NormalizedWe fromMe, attachments: extractAttachments(message), balloonBundleId, + associatedMessageGuid, + associatedMessageType, + associatedMessageEmoji, + isTapback, participants: normalizedParticipants, replyToId: replyMetadata.replyToId, replyToBody: replyMetadata.replyToBody, @@ -905,8 +1000,13 @@ function normalizeWebhookReaction(payload: Record): NormalizedW if (!associatedGuid || associatedType === undefined) return null; const mapping = REACTION_TYPE_MAP.get(associatedType); - const emoji = mapping?.emoji ?? `reaction:${associatedType}`; - const action = mapping?.action ?? "added"; + const associatedEmoji = + readString(message, "associatedMessageEmoji") ?? + readString(message, "associated_message_emoji") ?? + readString(message, "reactionEmoji") ?? + readString(message, "reaction_emoji"); + const emoji = (associatedEmoji?.trim() || mapping?.emoji) ?? `reaction:${associatedType}`; + const action = mapping?.action ?? resolveTapbackActionHint(associatedType) ?? "added"; const handleValue = message.handle ?? message.sender; const handle = @@ -1173,7 +1273,13 @@ async function processMessage( const placeholder = buildMessagePlaceholder(message); // Check if text is a tapback pattern (e.g., 'Loved "hello"') and transform to emoji format // For tapbacks, we'll append [[reply_to:N]] at the end; for regular messages, prepend it - const tapbackParsed = parseTapbackText(text); + const tapbackContext = resolveTapbackContext(message); + const tapbackParsed = parseTapbackText({ + text, + emojiHint: tapbackContext?.emojiHint, + actionHint: tapbackContext?.actionHint, + requireQuoted: !tapbackContext, + }); const isTapbackMessage = Boolean(tapbackParsed); const rawBody = tapbackParsed ? tapbackParsed.action === "removed" @@ -1506,6 +1612,10 @@ async function processMessage( let replyToSender = message.replyToSender; let replyToShortId: string | undefined; + if (isTapbackMessage && tapbackContext?.replyToId) { + replyToId = tapbackContext.replyToId; + } + if (replyToId && (!replyToBody || !replyToSender)) { const cached = resolveReplyContextFromCache({ accountId: account.accountId,