diff --git a/CHANGELOG.md b/CHANGELOG.md index efccc2942..7d34383dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.clawd.bot - Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal. ### Fixes +- BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.clawd.bot/channels/bluebubbles - BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing. - Web UI: hide internal `message_id` hints in chat bubbles. - Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent. diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md index eed40b681..1dd8e560d 100644 --- a/docs/channels/bluebubbles.md +++ b/docs/channels/bluebubbles.md @@ -213,6 +213,7 @@ Prefer `chat_guid` for stable routing: - `chat_id:123` - `chat_identifier:...` - Direct handles: `+15555550123`, `user@example.com` + - If a direct handle does not have an existing DM chat, Clawdbot will create one via `POST /api/v1/chat/new`. This requires the BlueBubbles Private API to be enabled. ## Security - Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted. diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index 6509ec3bf..84aa0ebf2 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -385,14 +385,14 @@ describe("send", () => { ).rejects.toThrow("password is required"); }); - it("throws when chatGuid cannot be resolved", async () => { + it("throws when chatGuid cannot be resolved for non-handle targets", async () => { mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ data: [] }), }); await expect( - sendMessageBlueBubbles("+15559999999", "Hello", { + sendMessageBlueBubbles("chat_id:999", "Hello", { serverUrl: "http://localhost:1234", password: "test", }), @@ -439,6 +439,57 @@ describe("send", () => { expect(body.method).toBeUndefined(); }); + it("creates a new chat when handle target is missing", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [] }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + data: { guid: "new-msg-guid" }, + }), + ), + }); + + const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(result.messageId).toBe("new-msg-guid"); + expect(mockFetch).toHaveBeenCalledTimes(2); + + const createCall = mockFetch.mock.calls[1]; + expect(createCall[0]).toContain("/api/v1/chat/new"); + const body = JSON.parse(createCall[1].body); + expect(body.addresses).toEqual(["+15550009999"]); + expect(body.message).toBe("Hello new chat"); + }); + + it("throws when creating a new chat requires Private API", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [] }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 403, + text: () => Promise.resolve("Private API not enabled"), + }); + + await expect( + sendMessageBlueBubbles("+15550008888", "Hello", { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("Private API must be enabled"); + }); + it("uses private-api when reply metadata is present", async () => { mockFetch .mockResolvedValueOnce({ diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index 9b592d9d2..ca1054c17 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -321,6 +321,44 @@ describe("runMessageAction context isolation", () => { }), ).rejects.toThrow(/Cross-context messaging denied/); }); + + it("aborts send when abortSignal is already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + + await expect( + runMessageAction({ + cfg: slackConfig, + action: "send", + params: { + channel: "slack", + target: "#C12345678", + message: "hi", + }, + dryRun: true, + abortSignal: controller.signal, + }), + ).rejects.toMatchObject({ name: "AbortError" }); + }); + + it("aborts broadcast when abortSignal is already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + + await expect( + runMessageAction({ + cfg: slackConfig, + action: "broadcast", + params: { + targets: ["channel:C12345678"], + channel: "slack", + message: "hi", + }, + dryRun: true, + abortSignal: controller.signal, + }), + ).rejects.toMatchObject({ name: "AbortError" }); + }); }); describe("runMessageAction sendAttachment hydration", () => { diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 2fe4cfeb6..8d02b743c 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -526,6 +526,7 @@ async function handleBroadcastAction( input: RunMessageActionParams, params: Record, ): Promise { + throwIfAborted(input.abortSignal); const broadcastEnabled = input.cfg.tools?.message?.broadcast?.enabled !== false; if (!broadcastEnabled) { throw new Error("Broadcast is disabled. Set tools.message.broadcast.enabled to true."); @@ -550,8 +551,11 @@ async function handleBroadcastAction( error?: string; result?: MessageSendResult; }> = []; + const isAbortError = (err: unknown): boolean => err instanceof Error && err.name === "AbortError"; for (const targetChannel of targetChannels) { + throwIfAborted(input.abortSignal); for (const target of rawTargets) { + throwIfAborted(input.abortSignal); try { const resolved = await resolveChannelTarget({ cfg: input.cfg, @@ -575,6 +579,7 @@ async function handleBroadcastAction( result: sendResult.kind === "send" ? sendResult.sendResult : undefined, }); } catch (err) { + if (isAbortError(err)) throw err; results.push({ channel: targetChannel, to: target,