feat(bluebubbles): improve reaction handling and inline reply tags (#1641)

* refactor: update reply formatting to use inline [[reply_to:N]] tag and normalize message IDs

* test: add unit tests for tapback text parsing in BlueBubbles webhook

* refactor: update message ID handling to use GUIDs instead of UUIDs for consistency
This commit is contained in:
Tyler Yust
2026-01-24 14:42:42 -08:00
committed by GitHub
parent c2d68a87f7
commit 445b58550c
2 changed files with 317 additions and 55 deletions

View File

@@ -1189,9 +1189,8 @@ describe("BlueBubbles webhook monitor", () => {
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
expect(callArgs.ctx.ReplyToBody).toBe("original message");
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
// Body uses just the ID (no sender) for token savings
expect(callArgs.ctx.Body).toContain("[Replying to id:msg-0]");
expect(callArgs.ctx.Body).toContain("original message");
// Body uses inline [[reply_to:N]] tag format
expect(callArgs.ctx.Body).toContain("[[reply_to:msg-0]]");
});
it("hydrates missing reply sender/body from the recent-message cache", async () => {
@@ -1260,9 +1259,8 @@ describe("BlueBubbles webhook monitor", () => {
expect(callArgs.ctx.ReplyToIdFull).toBe("cache-msg-0");
expect(callArgs.ctx.ReplyToBody).toBe("original message (cached)");
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
// Body uses just the short ID (no sender) for token savings
expect(callArgs.ctx.Body).toContain("[Replying to id:1]");
expect(callArgs.ctx.Body).toContain("original message (cached)");
// Body uses inline [[reply_to:N]] tag format with short ID
expect(callArgs.ctx.Body).toContain("[[reply_to:1]]");
});
it("falls back to threadOriginatorGuid when reply metadata is absent", async () => {
@@ -1305,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");
@@ -1759,7 +1839,7 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("reaction added"),
expect.stringContaining("reacted with ❤️ [[reply_to:"),
expect.any(Object),
);
});
@@ -1799,7 +1879,7 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("reaction removed"),
expect.stringContaining("removed ❤️ reaction [[reply_to:"),
expect.any(Object),
);
});
@@ -1905,7 +1985,7 @@ describe("BlueBubbles webhook monitor", () => {
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-uuid-12345",
guid: "p:1/msg-uuid-12345",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
@@ -1921,7 +2001,7 @@ describe("BlueBubbles webhook monitor", () => {
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
// MessageSid should be short ID "1" instead of full UUID
expect(callArgs.ctx.MessageSid).toBe("1");
expect(callArgs.ctx.MessageSidFull).toBe("msg-uuid-12345");
expect(callArgs.ctx.MessageSidFull).toBe("p:1/msg-uuid-12345");
});
it("resolves short ID back to UUID", async () => {
@@ -1945,7 +2025,7 @@ describe("BlueBubbles webhook monitor", () => {
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-uuid-12345",
guid: "p:1/msg-uuid-12345",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
@@ -1958,7 +2038,7 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
// The short ID "1" should resolve back to the full UUID
expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345");
expect(resolveBlueBubblesMessageId("1")).toBe("p:1/msg-uuid-12345");
});
it("returns UUID unchanged when not in cache", () => {