From cd25d69b4d54c09c36ef9ce1022bb3abccd660cd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 17:05:36 +0000 Subject: [PATCH] fix: harden bluebubbles short ids and fetch wrapper (#1369) (thanks @tyler6204) --- CHANGELOG.md | 2 + docs/gateway/configuration.md | 3 + extensions/bluebubbles/src/actions.test.ts | 104 +++++++++++++++++++++ extensions/bluebubbles/src/actions.ts | 8 +- extensions/bluebubbles/src/channel.ts | 4 +- extensions/bluebubbles/src/media-send.ts | 2 +- extensions/bluebubbles/src/monitor.test.ts | 6 ++ extensions/bluebubbles/src/monitor.ts | 12 ++- src/cli/node-cli/register.ts | 1 - src/infra/fetch.ts | 5 +- 10 files changed, 136 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46f4ac09b..7474dfd2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ Docs: https://docs.clawd.bot - Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5. - Configure: seed model fallbacks from the allowlist selection when multiple models are chosen. - Model picker: list the full catalog when no model allowlist is configured. +- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1369) Thanks @tyler6204. +- Infra: preserve fetch helper methods when wrapping abort signals. (#1369) ## 2026.1.20 diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index acf4ee219..1ab4c4bb0 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -3022,6 +3022,9 @@ Template placeholders are expanded in `tools.media.*.models[].args` and `tools.m | `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per channel) | | `{{To}}` | Destination identifier | | `{{MessageSid}}` | Channel message id (when available) | +| `{{MessageSidFull}}` | Provider-specific full message id when `MessageSid` is shortened | +| `{{ReplyToId}}` | Reply-to message id (when available) | +| `{{ReplyToIdFull}}` | Provider-specific full reply-to id when `ReplyToId` is shortened | | `{{SessionId}}` | Current session UUID | | `{{IsNewSession}}` | `"true"` when a new session was created | | `{{MediaUrl}}` | Inbound media pseudo-URL (if present) | diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts index 21f1c9c9d..157776b1c 100644 --- a/extensions/bluebubbles/src/actions.test.ts +++ b/extensions/bluebubbles/src/actions.test.ts @@ -38,6 +38,10 @@ vi.mock("./attachments.js", () => ({ sendBlueBubblesAttachment: vi.fn().mockResolvedValue({ messageId: "att-msg-123" }), })); +vi.mock("./monitor.js", () => ({ + resolveBlueBubblesMessageId: vi.fn((id: string) => id), +})); + describe("bluebubblesMessageActions", () => { beforeEach(() => { vi.clearAllMocks(); @@ -358,6 +362,106 @@ describe("bluebubblesMessageActions", () => { ); }); + it("uses toolContext currentChannelId when no explicit target is provided", async () => { + const { sendBlueBubblesReaction } = await import("./reactions.js"); + const { resolveChatGuidForTarget } = await import("./send.js"); + vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15550001111"); + + const cfg: ClawdbotConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + await bluebubblesMessageActions.handleAction({ + action: "react", + params: { + emoji: "👍", + messageId: "msg-456", + }, + cfg, + accountId: null, + toolContext: { + currentChannelId: "bluebubbles:chat_guid:iMessage;-;+15550001111", + }, + }); + + expect(resolveChatGuidForTarget).toHaveBeenCalledWith( + expect.objectContaining({ + target: { kind: "chat_guid", chatGuid: "iMessage;-;+15550001111" }, + }), + ); + expect(sendBlueBubblesReaction).toHaveBeenCalledWith( + expect.objectContaining({ + chatGuid: "iMessage;-;+15550001111", + }), + ); + }); + + it("resolves short messageId before reacting", async () => { + const { resolveBlueBubblesMessageId } = await import("./monitor.js"); + const { sendBlueBubblesReaction } = await import("./reactions.js"); + vi.mocked(resolveBlueBubblesMessageId).mockReturnValueOnce("resolved-uuid"); + + const cfg: ClawdbotConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + + await bluebubblesMessageActions.handleAction({ + action: "react", + params: { + emoji: "❤️", + messageId: "1", + chatGuid: "iMessage;-;+15551234567", + }, + cfg, + accountId: null, + }); + + expect(resolveBlueBubblesMessageId).toHaveBeenCalledWith("1", { requireKnownShortId: true }); + expect(sendBlueBubblesReaction).toHaveBeenCalledWith( + expect.objectContaining({ + messageGuid: "resolved-uuid", + }), + ); + }); + + it("propagates short-id errors from the resolver", async () => { + const { resolveBlueBubblesMessageId } = await import("./monitor.js"); + vi.mocked(resolveBlueBubblesMessageId).mockImplementationOnce(() => { + throw new Error("short id expired"); + }); + + const cfg: ClawdbotConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + + await expect( + bluebubblesMessageActions.handleAction({ + action: "react", + params: { + emoji: "❤️", + messageId: "999", + chatGuid: "iMessage;-;+15551234567", + }, + cfg, + accountId: null, + }), + ).rejects.toThrow("short id expired"); + }); + it("accepts message param for edit action", async () => { const { editBlueBubblesMessage } = await import("./chat.js"); diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index 3630f91fa..8097add2c 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -143,7 +143,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { ); } // Resolve short ID (e.g., "1", "2") to full UUID - const messageId = resolveBlueBubblesMessageId(rawMessageId); + const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true }); const partIndex = readNumberParam(params, "partIndex", { integer: true }); const resolvedChatGuid = await resolveChatGuid(); @@ -183,7 +183,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { ); } // Resolve short ID (e.g., "1", "2") to full UUID - const messageId = resolveBlueBubblesMessageId(rawMessageId); + const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true }); const partIndex = readNumberParam(params, "partIndex", { integer: true }); const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage"); @@ -206,7 +206,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { ); } // Resolve short ID (e.g., "1", "2") to full UUID - const messageId = resolveBlueBubblesMessageId(rawMessageId); + const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true }); const partIndex = readNumberParam(params, "partIndex", { integer: true }); await unsendBlueBubblesMessage(messageId, { @@ -233,7 +233,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { ); } // Resolve short ID (e.g., "1", "2") to full UUID - const messageId = resolveBlueBubblesMessageId(rawMessageId); + const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true }); const partIndex = readNumberParam(params, "partIndex", { integer: true }); const result = await sendMessageBlueBubbles(to, text, { diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index f316b499f..5fcb75794 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -240,7 +240,9 @@ export const bluebubblesPlugin: ChannelPlugin = { sendText: async ({ cfg, to, text, accountId, replyToId }) => { const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : ""; // Resolve short ID (e.g., "5") to full UUID - const replyToMessageGuid = rawReplyToId ? resolveBlueBubblesMessageId(rawReplyToId) : ""; + const replyToMessageGuid = rawReplyToId + ? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true }) + : ""; const result = await sendMessageBlueBubbles(to, text, { cfg: cfg as ClawdbotConfig, accountId: accountId ?? undefined, diff --git a/extensions/bluebubbles/src/media-send.ts b/extensions/bluebubbles/src/media-send.ts index 5fc9895e3..5fc2225cc 100644 --- a/extensions/bluebubbles/src/media-send.ts +++ b/extensions/bluebubbles/src/media-send.ts @@ -137,7 +137,7 @@ export async function sendBlueBubblesMedia(params: { // Resolve short ID (e.g., "5") to full UUID const replyToMessageGuid = replyToId?.trim() - ? resolveBlueBubblesMessageId(replyToId.trim()) + ? resolveBlueBubblesMessageId(replyToId.trim(), { requireKnownShortId: true }) : undefined; const attachmentResult = await sendBlueBubblesAttachment({ diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 2806bf82f..ee9b15084 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -1860,6 +1860,12 @@ describe("BlueBubbles webhook monitor", () => { it("returns short ID unchanged when numeric but not in cache", () => { expect(resolveBlueBubblesMessageId("999")).toBe("999"); }); + + it("throws when numeric short ID is missing and requireKnownShortId is set", () => { + expect(() => + resolveBlueBubblesMessageId("999", { requireKnownShortId: true }), + ).toThrow(/short message id/i); + }); }); describe("fromMe messages", () => { diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 3e89f88fa..f55383068 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -119,7 +119,10 @@ function rememberBlueBubblesReplyCache( * Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles UUID. * Returns the input unchanged if it's already a UUID or not found in the mapping. */ -export function resolveBlueBubblesMessageId(shortOrUuid: string): string { +export function resolveBlueBubblesMessageId( + shortOrUuid: string, + opts?: { requireKnownShortId?: boolean }, +): string { const trimmed = shortOrUuid.trim(); if (!trimmed) return trimmed; @@ -127,6 +130,11 @@ export function resolveBlueBubblesMessageId(shortOrUuid: string): string { if (/^\d+$/.test(trimmed)) { const uuid = blueBubblesShortIdToUuid.get(trimmed); if (uuid) return uuid; + if (opts?.requireKnownShortId) { + throw new Error( + `BlueBubbles short message id "${trimmed}" is no longer available. Use MessageSidFull.`, + ); + } } // Return as-is (either already a UUID or not found) @@ -1646,7 +1654,7 @@ async function processMessage( const rawReplyToId = typeof payload.replyToId === "string" ? payload.replyToId.trim() : ""; // Resolve short ID (e.g., "5") to full UUID const replyToMessageGuid = rawReplyToId - ? resolveBlueBubblesMessageId(rawReplyToId) + ? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true }) : ""; const mediaList = payload.mediaUrls?.length ? payload.mediaUrls diff --git a/src/cli/node-cli/register.ts b/src/cli/node-cli/register.ts index 9a7412111..8712c6a44 100644 --- a/src/cli/node-cli/register.ts +++ b/src/cli/node-cli/register.ts @@ -6,7 +6,6 @@ import { runNodeHost } from "../../node-host/runner.js"; import { runNodeDaemonInstall, runNodeDaemonRestart, - runNodeDaemonStart, runNodeDaemonStatus, runNodeDaemonStop, runNodeDaemonUninstall, diff --git a/src/infra/fetch.ts b/src/infra/fetch.ts index 5cd0d94e6..6a472253b 100644 --- a/src/infra/fetch.ts +++ b/src/infra/fetch.ts @@ -1,5 +1,5 @@ export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch { - return (input: RequestInfo | URL, init?: RequestInit) => { + const wrapped = ((input: RequestInfo | URL, init?: RequestInit) => { const signal = init?.signal; if (!signal) return fetchImpl(input, init); if (typeof AbortSignal !== "undefined" && signal instanceof AbortSignal) { @@ -25,5 +25,6 @@ export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch }); } return response; - }; + }) as typeof fetch; + return Object.assign(wrapped, fetchImpl); }