From 07bf572f35bedd0bc66adc3cbd11bd7574d81695 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 7 May 2026 04:25:10 -0700 Subject: [PATCH] chore(channels): delete bluebubbles plugin package --- docs/channels/bluebubbles.md | 638 ---- docs/plugins/reference/bluebubbles.md | 23 - extensions/bluebubbles/README.md | 45 - extensions/bluebubbles/api.ts | 19 - extensions/bluebubbles/channel-config-api.ts | 1 - extensions/bluebubbles/channel-plugin-api.ts | 3 - extensions/bluebubbles/contract-api.ts | 8 - extensions/bluebubbles/doctor-contract-api.ts | 1 - extensions/bluebubbles/index.ts | 20 - extensions/bluebubbles/openclaw.plugin.json | 12 - extensions/bluebubbles/package.json | 66 - extensions/bluebubbles/runtime-api.ts | 5 - extensions/bluebubbles/secret-contract-api.ts | 5 - extensions/bluebubbles/setup-entry.ts | 13 - .../bluebubbles/src/account-resolve.test.ts | 149 - extensions/bluebubbles/src/account-resolve.ts | 77 - .../bluebubbles/src/accounts-normalization.ts | 107 - extensions/bluebubbles/src/accounts.ts | 94 - extensions/bluebubbles/src/actions-api.ts | 5 - .../bluebubbles/src/actions-contract.ts | 19 - extensions/bluebubbles/src/actions.runtime.ts | 31 - extensions/bluebubbles/src/actions.test.ts | 764 ----- extensions/bluebubbles/src/actions.ts | 531 ---- .../bluebubbles/src/attachments.test.ts | 836 ----- extensions/bluebubbles/src/attachments.ts | 302 -- extensions/bluebubbles/src/catchup.test.ts | 1125 ------- extensions/bluebubbles/src/catchup.ts | 652 ---- extensions/bluebubbles/src/channel-shared.ts | 81 - .../src/channel.message-adapter.test.ts | 183 -- .../bluebubbles/src/channel.pairing.test.ts | 48 - extensions/bluebubbles/src/channel.runtime.ts | 19 - extensions/bluebubbles/src/channel.setup.ts | 31 - .../bluebubbles/src/channel.status.test.ts | 80 - extensions/bluebubbles/src/channel.ts | 528 ---- extensions/bluebubbles/src/chat.test.ts | 621 ---- extensions/bluebubbles/src/chat.ts | 305 -- extensions/bluebubbles/src/client.test.ts | 604 ---- extensions/bluebubbles/src/client.ts | 582 ---- extensions/bluebubbles/src/config-apply.ts | 84 - extensions/bluebubbles/src/config-schema.ts | 182 -- extensions/bluebubbles/src/config-ui-hints.ts | 12 - .../src/conversation-bindings.test.ts | 64 - .../bluebubbles/src/conversation-bindings.ts | 46 - extensions/bluebubbles/src/conversation-id.ts | 77 - .../src/conversation-route.test.ts | 63 - .../bluebubbles/src/conversation-route.ts | 68 - extensions/bluebubbles/src/doctor-contract.ts | 9 - extensions/bluebubbles/src/doctor.test.ts | 40 - extensions/bluebubbles/src/doctor.ts | 10 - extensions/bluebubbles/src/group-policy.ts | 40 - extensions/bluebubbles/src/history.ts | 183 -- .../bluebubbles/src/inbound-dedupe.test.ts | 136 - extensions/bluebubbles/src/inbound-dedupe.ts | 261 -- extensions/bluebubbles/src/media-send.test.ts | 356 --- extensions/bluebubbles/src/media-send.ts | 201 -- .../bluebubbles/src/monitor-debounce.ts | 337 -- .../bluebubbles/src/monitor-normalize.test.ts | 206 -- .../bluebubbles/src/monitor-normalize.ts | 884 ------ .../bluebubbles/src/monitor-processing-api.ts | 20 - .../monitor-processing-chat-resolve.test.ts | 137 - .../bluebubbles/src/monitor-processing.ts | 2218 ------------- .../src/monitor-reply-cache.test.ts | 333 -- .../bluebubbles/src/monitor-reply-cache.ts | 336 -- .../src/monitor-reply-fetch.test.ts | 498 --- .../bluebubbles/src/monitor-reply-fetch.ts | 220 -- .../src/monitor-self-chat-cache.test.ts | 190 -- .../src/monitor-self-chat-cache.ts | 123 - extensions/bluebubbles/src/monitor-shared.ts | 33 - extensions/bluebubbles/src/monitor.test.ts | 2800 ----------------- extensions/bluebubbles/src/monitor.ts | 398 --- .../src/monitor.webhook-auth.test.ts | 701 ----- .../src/monitor.webhook.test-helpers.ts | 374 --- extensions/bluebubbles/src/multipart.ts | 57 - extensions/bluebubbles/src/pairing.ts | 37 - .../src/participant-contact-names.test.ts | 198 -- .../src/participant-contact-names.ts | 378 --- extensions/bluebubbles/src/probe.ts | 169 - extensions/bluebubbles/src/reactions.test.ts | 422 --- extensions/bluebubbles/src/reactions.ts | 203 -- extensions/bluebubbles/src/request-url.ts | 1 - extensions/bluebubbles/src/runtime-api.ts | 61 - extensions/bluebubbles/src/runtime.ts | 28 - extensions/bluebubbles/src/secret-contract.ts | 59 - extensions/bluebubbles/src/secret-input.ts | 6 - extensions/bluebubbles/src/send-helpers.ts | 61 - extensions/bluebubbles/src/send.test.ts | 1648 ---------- extensions/bluebubbles/src/send.ts | 679 ---- .../bluebubbles/src/session-route.test.ts | 89 - extensions/bluebubbles/src/session-route.ts | 55 - extensions/bluebubbles/src/setup-core.ts | 99 - .../bluebubbles/src/setup-surface.test.ts | 895 ------ extensions/bluebubbles/src/setup-surface.ts | 299 -- .../bluebubbles/src/status-issues.test.ts | 55 - extensions/bluebubbles/src/status-issues.ts | 102 - extensions/bluebubbles/src/targets.ts | 477 --- extensions/bluebubbles/src/test-harness.ts | 155 - extensions/bluebubbles/src/test-helpers.ts | 52 - extensions/bluebubbles/src/test-mocks.ts | 11 - .../src/test-support/monitor-test-support.ts | 134 - extensions/bluebubbles/src/types.ts | 223 -- extensions/bluebubbles/src/webhook-ingress.ts | 10 - extensions/bluebubbles/src/webhook-shared.ts | 15 - extensions/bluebubbles/tsconfig.json | 16 - skills/bluebubbles/SKILL.md | 131 - src/channels/plugins/bluebubbles-actions.ts | 25 - src/plugin-sdk/bluebubbles-policy.ts | 49 - src/plugin-sdk/bluebubbles.test.ts | 50 - src/plugin-sdk/bluebubbles.ts | 159 - .../vitest.extension-bluebubbles-paths.mjs | 5 - .../vitest.extension-bluebubbles.config.ts | 27 - 110 files changed, 27413 deletions(-) delete mode 100644 docs/channels/bluebubbles.md delete mode 100644 docs/plugins/reference/bluebubbles.md delete mode 100644 extensions/bluebubbles/README.md delete mode 100644 extensions/bluebubbles/api.ts delete mode 100644 extensions/bluebubbles/channel-config-api.ts delete mode 100644 extensions/bluebubbles/channel-plugin-api.ts delete mode 100644 extensions/bluebubbles/contract-api.ts delete mode 100644 extensions/bluebubbles/doctor-contract-api.ts delete mode 100644 extensions/bluebubbles/index.ts delete mode 100644 extensions/bluebubbles/openclaw.plugin.json delete mode 100644 extensions/bluebubbles/package.json delete mode 100644 extensions/bluebubbles/runtime-api.ts delete mode 100644 extensions/bluebubbles/secret-contract-api.ts delete mode 100644 extensions/bluebubbles/setup-entry.ts delete mode 100644 extensions/bluebubbles/src/account-resolve.test.ts delete mode 100644 extensions/bluebubbles/src/account-resolve.ts delete mode 100644 extensions/bluebubbles/src/accounts-normalization.ts delete mode 100644 extensions/bluebubbles/src/accounts.ts delete mode 100644 extensions/bluebubbles/src/actions-api.ts delete mode 100644 extensions/bluebubbles/src/actions-contract.ts delete mode 100644 extensions/bluebubbles/src/actions.runtime.ts delete mode 100644 extensions/bluebubbles/src/actions.test.ts delete mode 100644 extensions/bluebubbles/src/actions.ts delete mode 100644 extensions/bluebubbles/src/attachments.test.ts delete mode 100644 extensions/bluebubbles/src/attachments.ts delete mode 100644 extensions/bluebubbles/src/catchup.test.ts delete mode 100644 extensions/bluebubbles/src/catchup.ts delete mode 100644 extensions/bluebubbles/src/channel-shared.ts delete mode 100644 extensions/bluebubbles/src/channel.message-adapter.test.ts delete mode 100644 extensions/bluebubbles/src/channel.pairing.test.ts delete mode 100644 extensions/bluebubbles/src/channel.runtime.ts delete mode 100644 extensions/bluebubbles/src/channel.setup.ts delete mode 100644 extensions/bluebubbles/src/channel.status.test.ts delete mode 100644 extensions/bluebubbles/src/channel.ts delete mode 100644 extensions/bluebubbles/src/chat.test.ts delete mode 100644 extensions/bluebubbles/src/chat.ts delete mode 100644 extensions/bluebubbles/src/client.test.ts delete mode 100644 extensions/bluebubbles/src/client.ts delete mode 100644 extensions/bluebubbles/src/config-apply.ts delete mode 100644 extensions/bluebubbles/src/config-schema.ts delete mode 100644 extensions/bluebubbles/src/config-ui-hints.ts delete mode 100644 extensions/bluebubbles/src/conversation-bindings.test.ts delete mode 100644 extensions/bluebubbles/src/conversation-bindings.ts delete mode 100644 extensions/bluebubbles/src/conversation-id.ts delete mode 100644 extensions/bluebubbles/src/conversation-route.test.ts delete mode 100644 extensions/bluebubbles/src/conversation-route.ts delete mode 100644 extensions/bluebubbles/src/doctor-contract.ts delete mode 100644 extensions/bluebubbles/src/doctor.test.ts delete mode 100644 extensions/bluebubbles/src/doctor.ts delete mode 100644 extensions/bluebubbles/src/group-policy.ts delete mode 100644 extensions/bluebubbles/src/history.ts delete mode 100644 extensions/bluebubbles/src/inbound-dedupe.test.ts delete mode 100644 extensions/bluebubbles/src/inbound-dedupe.ts delete mode 100644 extensions/bluebubbles/src/media-send.test.ts delete mode 100644 extensions/bluebubbles/src/media-send.ts delete mode 100644 extensions/bluebubbles/src/monitor-debounce.ts delete mode 100644 extensions/bluebubbles/src/monitor-normalize.test.ts delete mode 100644 extensions/bluebubbles/src/monitor-normalize.ts delete mode 100644 extensions/bluebubbles/src/monitor-processing-api.ts delete mode 100644 extensions/bluebubbles/src/monitor-processing-chat-resolve.test.ts delete mode 100644 extensions/bluebubbles/src/monitor-processing.ts delete mode 100644 extensions/bluebubbles/src/monitor-reply-cache.test.ts delete mode 100644 extensions/bluebubbles/src/monitor-reply-cache.ts delete mode 100644 extensions/bluebubbles/src/monitor-reply-fetch.test.ts delete mode 100644 extensions/bluebubbles/src/monitor-reply-fetch.ts delete mode 100644 extensions/bluebubbles/src/monitor-self-chat-cache.test.ts delete mode 100644 extensions/bluebubbles/src/monitor-self-chat-cache.ts delete mode 100644 extensions/bluebubbles/src/monitor-shared.ts delete mode 100644 extensions/bluebubbles/src/monitor.test.ts delete mode 100644 extensions/bluebubbles/src/monitor.ts delete mode 100644 extensions/bluebubbles/src/monitor.webhook-auth.test.ts delete mode 100644 extensions/bluebubbles/src/monitor.webhook.test-helpers.ts delete mode 100644 extensions/bluebubbles/src/multipart.ts delete mode 100644 extensions/bluebubbles/src/pairing.ts delete mode 100644 extensions/bluebubbles/src/participant-contact-names.test.ts delete mode 100644 extensions/bluebubbles/src/participant-contact-names.ts delete mode 100644 extensions/bluebubbles/src/probe.ts delete mode 100644 extensions/bluebubbles/src/reactions.test.ts delete mode 100644 extensions/bluebubbles/src/reactions.ts delete mode 100644 extensions/bluebubbles/src/request-url.ts delete mode 100644 extensions/bluebubbles/src/runtime-api.ts delete mode 100644 extensions/bluebubbles/src/runtime.ts delete mode 100644 extensions/bluebubbles/src/secret-contract.ts delete mode 100644 extensions/bluebubbles/src/secret-input.ts delete mode 100644 extensions/bluebubbles/src/send-helpers.ts delete mode 100644 extensions/bluebubbles/src/send.test.ts delete mode 100644 extensions/bluebubbles/src/send.ts delete mode 100644 extensions/bluebubbles/src/session-route.test.ts delete mode 100644 extensions/bluebubbles/src/session-route.ts delete mode 100644 extensions/bluebubbles/src/setup-core.ts delete mode 100644 extensions/bluebubbles/src/setup-surface.test.ts delete mode 100644 extensions/bluebubbles/src/setup-surface.ts delete mode 100644 extensions/bluebubbles/src/status-issues.test.ts delete mode 100644 extensions/bluebubbles/src/status-issues.ts delete mode 100644 extensions/bluebubbles/src/targets.ts delete mode 100644 extensions/bluebubbles/src/test-harness.ts delete mode 100644 extensions/bluebubbles/src/test-helpers.ts delete mode 100644 extensions/bluebubbles/src/test-mocks.ts delete mode 100644 extensions/bluebubbles/src/test-support/monitor-test-support.ts delete mode 100644 extensions/bluebubbles/src/types.ts delete mode 100644 extensions/bluebubbles/src/webhook-ingress.ts delete mode 100644 extensions/bluebubbles/src/webhook-shared.ts delete mode 100644 extensions/bluebubbles/tsconfig.json delete mode 100644 skills/bluebubbles/SKILL.md delete mode 100644 src/channels/plugins/bluebubbles-actions.ts delete mode 100644 src/plugin-sdk/bluebubbles-policy.ts delete mode 100644 src/plugin-sdk/bluebubbles.test.ts delete mode 100644 src/plugin-sdk/bluebubbles.ts delete mode 100644 test/vitest/vitest.extension-bluebubbles-paths.mjs delete mode 100644 test/vitest/vitest.extension-bluebubbles.config.ts diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md deleted file mode 100644 index cfb7a155979d..000000000000 --- a/docs/channels/bluebubbles.md +++ /dev/null @@ -1,638 +0,0 @@ ---- -summary: "Legacy iMessage support via the BlueBubbles macOS server (REST send/receive, typing, reactions, pairing, advanced actions)." -read_when: - - Setting up BlueBubbles channel - - Troubleshooting webhook pairing - - Configuring iMessage on macOS -title: "BlueBubbles" -sidebarTitle: "BlueBubbles" ---- - -Status: bundled legacy plugin that talks to the BlueBubbles macOS server over HTTP. Existing BlueBubbles setups continue to work, but new OpenClaw iMessage deployments should prefer the native [iMessage](/channels/imessage) plugin when its requirements fit your host. - - -BlueBubbles is deprecated for new OpenClaw setups. - -The upstream BlueBubbles ecosystem is still active, but OpenClaw depends on the BlueBubbles macOS server API. As of May 6, 2026, the official [`bluebubbles-server`](https://github.com/BlueBubblesApp/bluebubbles-server) development branch last changed on [January 22, 2026](https://github.com/BlueBubblesApp/bluebubbles-server/commit/88a4921bbd5a8111f1e9582b83715cf877171037), and the latest server release ([`v1.9.9`](https://github.com/BlueBubblesApp/bluebubbles-server/releases/tag/v1.9.9)) was published on May 16, 2025. The client app and helper repositories have newer activity, so this is not an abandonment claim; the deprecation is about reducing OpenClaw's dependency on an external HTTP server, webhooks, and private-API compatibility surface when the native `imsg` path keeps the integration on a local stdio contract. - - - -Current OpenClaw releases bundle BlueBubbles, so normal packaged builds do not need a separate `openclaw plugins install` step. - - -## Overview - -- Runs on macOS via the BlueBubbles helper app ([bluebubbles.app](https://bluebubbles.app)). -- Legacy fallback for installations that already rely on BlueBubbles channel IDs, webhook state, group targets, cron delivery, or workspace routing. -- Recommended/tested: macOS Sequoia (15). macOS Tahoe (26) works; edit is currently broken on Tahoe, and group icon updates may report success but not sync. -- OpenClaw talks to it through its REST API (`GET /api/v1/ping`, `POST /message/text`, `POST /chat/:id/*`). -- Incoming messages arrive via webhooks; outgoing replies, typing indicators, read receipts, and tapbacks are REST calls. -- Attachments and stickers are ingested as inbound media (and surfaced to the agent when possible). -- Auto-TTS replies that synthesize MP3 or CAF audio are delivered as iMessage voice memo bubbles instead of plain file attachments. -- Pairing/allowlist works the same way as other channels (`/channels/pairing` etc) with `channels.bluebubbles.allowFrom` + pairing codes. -- Reactions are surfaced as system events just like Slack/Telegram so agents can "mention" them before replying. -- Advanced features: edit, unsend, reply threading, message effects, group management. - -## Quick start - - - - Install the BlueBubbles server on your Mac (follow the instructions at [bluebubbles.app/install](https://bluebubbles.app/install)). - - - In the BlueBubbles config, enable the web API and set a password. - - - Run `openclaw onboard` and select BlueBubbles, or configure manually: - - ```json5 - { - channels: { - bluebubbles: { - enabled: true, - serverUrl: "http://192.168.1.100:1234", - password: "example-password", - webhookPath: "/bluebubbles-webhook", - }, - }, - } - ``` - - - - Point BlueBubbles webhooks to your gateway (example: `https://your-gateway-host:3000/bluebubbles-webhook?password=`). - - - Start the gateway; it will register the webhook handler and start pairing. - - - - -**Security** - -- Always set a webhook password. -- Webhook authentication is always required. OpenClaw rejects BlueBubbles webhook requests unless they include a password/guid that matches `channels.bluebubbles.password` (for example `?password=` or `x-password`), regardless of loopback/proxy topology. -- Password authentication is checked before reading/parsing full webhook bodies. - - - -## Keeping Messages.app alive (VM / headless setups) - -Some macOS VM / always-on setups can end up with Messages.app going "idle" (incoming events stop until the app is opened/foregrounded). A simple workaround is to **poke Messages every 5 minutes** using an AppleScript + LaunchAgent. - - - - Save this as `~/Scripts/poke-messages.scpt`: - - ```applescript - try - tell application "Messages" - if not running then - launch - end if - - -- Touch the scripting interface to keep the process responsive. - set _chatCount to (count of chats) - end tell - on error - -- Ignore transient failures (first-run prompts, locked session, etc). - end try - ``` - - - - Save this as `~/Library/LaunchAgents/com.user.poke-messages.plist`: - - ```xml - - - - - Label - com.user.poke-messages - - ProgramArguments - - /bin/bash - -lc - /usr/bin/osascript "$HOME/Scripts/poke-messages.scpt" - - - RunAtLoad - - - StartInterval - 300 - - StandardOutPath - /tmp/poke-messages.log - StandardErrorPath - /tmp/poke-messages.err - - - ``` - - This runs **every 300 seconds** and **on login**. The first run may trigger macOS **Automation** prompts (`osascript` → Messages). Approve them in the same user session that runs the LaunchAgent. - - - - ```bash - launchctl unload ~/Library/LaunchAgents/com.user.poke-messages.plist 2>/dev/null || true - launchctl load ~/Library/LaunchAgents/com.user.poke-messages.plist - ``` - - - -## Onboarding - -BlueBubbles is available in interactive onboarding: - -``` -openclaw onboard -``` - -The wizard prompts for: - - - BlueBubbles server address (e.g., `http://192.168.1.100:1234`). - - - API password from BlueBubbles Server settings. - - - Webhook endpoint path. - - - `pairing`, `allowlist`, `open`, or `disabled`. - - - Phone numbers, emails, or chat targets. - - -You can also add BlueBubbles via CLI: - -``` -openclaw channels add bluebubbles --http-url http://192.168.1.100:1234 --password -``` - -## Access control (DMs + groups) - - - - - Default: `channels.bluebubbles.dmPolicy = "pairing"`. - - Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour). - - Approve via: - - `openclaw pairing list bluebubbles` - - `openclaw pairing approve bluebubbles ` - - Pairing is the default token exchange. Details: [Pairing](/channels/pairing) - - - - - `channels.bluebubbles.groupPolicy = open | allowlist | disabled` (default: `allowlist`). - - `channels.bluebubbles.groupAllowFrom` controls who can trigger in groups when `allowlist` is set. - - - - -### Contact name enrichment (macOS, optional) - -BlueBubbles group webhooks often only include raw participant addresses. If you want `GroupMembers` context to show local contact names instead, you can opt in to local Contacts enrichment on macOS: - -- `channels.bluebubbles.enrichGroupParticipantsFromContacts = true` enables the lookup. Default: `false`. -- Lookups run only after group access, command authorization, and mention gating have allowed the message through. -- Only unnamed phone participants are enriched. -- Raw phone numbers remain as the fallback when no local match is found. - -```json5 -{ - channels: { - bluebubbles: { - enrichGroupParticipantsFromContacts: true, - }, - }, -} -``` - -### Mention gating (groups) - -BlueBubbles supports mention gating for group chats, matching iMessage/WhatsApp behavior: - -- Uses `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) to detect mentions. -- When `requireMention` is enabled for a group, the agent only responds when mentioned. -- Control commands from authorized senders bypass mention gating. - -Per-group configuration: - -```json5 -{ - channels: { - bluebubbles: { - groupPolicy: "allowlist", - groupAllowFrom: ["+15555550123"], - groups: { - "*": { requireMention: true }, // default for all groups - "iMessage;-;chat123": { requireMention: false }, // override for specific group - }, - }, - }, -} -``` - -### Command gating - -- Control commands (e.g., `/config`, `/model`) require authorization. -- Uses `allowFrom` and `groupAllowFrom` to determine command authorization. -- Authorized senders can run control commands even without mentioning in groups. - -### Per-group system prompt - -Each entry under `channels.bluebubbles.groups.*` accepts an optional `systemPrompt` string. The value is injected into the agent's system prompt on every turn that handles a message in that group, so you can set per-group persona or behavioral rules without editing agent prompts: - -```json5 -{ - channels: { - bluebubbles: { - groups: { - "iMessage;-;chat123": { - systemPrompt: "Keep responses under 3 sentences. Mirror the group's casual tone.", - }, - }, - }, - }, -} -``` - -The key matches whatever BlueBubbles reports as `chatGuid` / `chatIdentifier` / numeric `chatId` for the group, and a `"*"` wildcard entry provides a default for every group without an exact match (same pattern used by `requireMention` and per-group tool policies). Exact matches always win over the wildcard. DMs ignore this field; use agent-level or account-level prompt customization instead. - -#### Worked example: threaded replies and tapback reactions (Private API) - -With the BlueBubbles Private API enabled, inbound messages arrive with short message IDs (for example `[[reply_to:5]]`) and the agent can call `action=reply` to thread into a specific message or `action=react` to drop a tapback. A per-group `systemPrompt` is a reliable way to keep the agent choosing the right tool: - -```json5 -{ - channels: { - bluebubbles: { - groups: { - "iMessage;+;chat-family": { - systemPrompt: "When replying in this group, always call action=reply with the [[reply_to:N]] messageId from context so your response threads under the triggering message. Never send a new unlinked message. For short acknowledgements ('ok', 'got it', 'on it'), use action=react with an appropriate tapback emoji (❤️, 👍, 😂, ‼️, ❓) instead of sending a text reply.", - }, - }, - }, - }, -} -``` - -Tapback reactions and threaded replies both require the BlueBubbles Private API; see [Advanced actions](#advanced-actions) and [Message IDs](#message-ids-short-vs-full) for the underlying mechanics. - -## ACP conversation bindings - -BlueBubbles chats can be turned into durable ACP workspaces without changing the transport layer. - -Fast operator flow: - -- Run `/acp spawn codex --bind here` inside the DM or allowed group chat. -- Future messages in that same BlueBubbles conversation route to the spawned ACP session. -- `/new` and `/reset` reset the same bound ACP session in place. -- `/acp close` closes the ACP session and removes the binding. - -Configured persistent bindings are also supported through top-level `bindings[]` entries with `type: "acp"` and `match.channel: "bluebubbles"`. - -`match.peer.id` can use any supported BlueBubbles target form: - -- normalized DM handle such as `+15555550123` or `user@example.com` -- `chat_id:` -- `chat_guid:` -- `chat_identifier:` - -For stable group bindings, prefer `chat_id:*` or `chat_identifier:*`. - -Example: - -```json5 -{ - agents: { - list: [ - { - id: "codex", - runtime: { - type: "acp", - acp: { agent: "codex", backend: "acpx", mode: "persistent" }, - }, - }, - ], - }, - bindings: [ - { - type: "acp", - agentId: "codex", - match: { - channel: "bluebubbles", - accountId: "default", - peer: { kind: "dm", id: "+15555550123" }, - }, - acp: { label: "codex-imessage" }, - }, - ], -} -``` - -See [ACP Agents](/tools/acp-agents) for shared ACP binding behavior. - -## Typing + read receipts - -- **Typing indicators**: Sent automatically before and during response generation. -- **Read receipts**: Controlled by `channels.bluebubbles.sendReadReceipts` (default: `true`). -- **Typing indicators**: OpenClaw sends typing start events; BlueBubbles clears typing automatically on send or timeout (manual stop via DELETE is unreliable). - -```json5 -{ - channels: { - bluebubbles: { - sendReadReceipts: false, // disable read receipts - }, - }, -} -``` - -## Advanced actions - -BlueBubbles supports advanced message actions when enabled in config: - -```json5 -{ - channels: { - bluebubbles: { - actions: { - reactions: true, // tapbacks (default: true) - edit: true, // edit sent messages (macOS 13+, broken on macOS 26 Tahoe) - unsend: true, // unsend messages (macOS 13+) - reply: true, // reply threading by message GUID - sendWithEffect: true, // message effects (slam, loud, etc.) - renameGroup: true, // rename group chats - setGroupIcon: true, // set group chat icon/photo (flaky on macOS 26 Tahoe) - addParticipant: true, // add participants to groups - removeParticipant: true, // remove participants from groups - leaveGroup: true, // leave group chats - sendAttachment: true, // send attachments/media - }, - }, - }, -} -``` - - - - - **react**: Add/remove tapback reactions (`messageId`, `emoji`, `remove`). iMessage's native tapback set is `love`, `like`, `dislike`, `laugh`, `emphasize`, and `question`. When an agent picks an emoji outside that set (for example `👀`), the reaction tool falls back to `love` so the tapback still renders instead of failing the whole request. Configured ack reactions still validate strictly and error on unknown values. - - **edit**: Edit a sent message (`messageId`, `text`). - - **unsend**: Unsend a message (`messageId`). - - **reply**: Reply to a specific message (`messageId`, `text`, `to`). - - **sendWithEffect**: Send with iMessage effect (`text`, `to`, `effectId`). - - **renameGroup**: Rename a group chat (`chatGuid`, `displayName`). - - **setGroupIcon**: Set a group chat's icon/photo (`chatGuid`, `media`) - flaky on macOS 26 Tahoe (API may return success but the icon does not sync). - - **addParticipant**: Add someone to a group (`chatGuid`, `address`). - - **removeParticipant**: Remove someone from a group (`chatGuid`, `address`). - - **leaveGroup**: Leave a group chat (`chatGuid`). - - **upload-file**: Send media/files (`to`, `buffer`, `filename`, `asVoice`). - - Voice memos: set `asVoice: true` with **MP3** or **CAF** audio to send as an iMessage voice message. BlueBubbles converts MP3 → CAF when sending voice memos. - - Legacy alias: `sendAttachment` still works, but `upload-file` is the canonical action name. - - - - -### Message IDs (short vs full) - -OpenClaw may surface _short_ message IDs (e.g., `1`, `2`) to save tokens. - -- `MessageSid` / `ReplyToId` can be short IDs. -- `MessageSidFull` / `ReplyToIdFull` contain the provider full IDs. -- Short IDs are in-memory; they can expire on restart or cache eviction. -- Actions accept short or full `messageId`, but short IDs will error if no longer available. - -Use full IDs for durable automations and storage: - -- Templates: `{{MessageSidFull}}`, `{{ReplyToIdFull}}` -- Context: `MessageSidFull` / `ReplyToIdFull` in inbound payloads - -See [Configuration](/gateway/configuration) for template variables. - - - -## Coalescing split-send DMs (command + URL in one composition) - -When a user types a command and a URL together in iMessage - e.g. `Dump https://example.com/article` - Apple splits the send into **two separate webhook deliveries**: - -1. A text message (`"Dump"`). -2. A URL-preview balloon (`"https://..."`) with OG-preview images as attachments. - -The two webhooks arrive at OpenClaw ~0.8-2.0 s apart on most setups. Without coalescing, the agent receives the command alone on turn 1, replies (often "send me the URL"), and only sees the URL on turn 2 - at which point the command context is already lost. - -`channels.bluebubbles.coalesceSameSenderDms` opts a DM into merging consecutive same-sender webhooks into a single agent turn. Group chats continue to key per-message so multi-user turn structure is preserved. - - - - Enable when: - - - You ship skills that expect `command + payload` in one message (dump, paste, save, queue, etc.). - - Your users paste URLs, images, or long content alongside commands. - - You can accept the added DM turn latency (see below). - - Leave disabled when: - - - You need minimum command latency for single-word DM triggers. - - All your flows are one-shot commands without payload follow-ups. - - - - ```json5 - { - channels: { - bluebubbles: { - coalesceSameSenderDms: true, // opt in (default: false) - }, - }, - } - ``` - - With the flag on and no explicit `messages.inbound.byChannel.bluebubbles`, the debounce window widens to **2500 ms** (the default for non-coalescing is 500 ms). The wider window is required - Apple's split-send cadence of 0.8-2.0 s does not fit in the tighter default. - - To tune the window yourself: - - ```json5 - { - messages: { - inbound: { - byChannel: { - // 2500 ms works for most setups; raise to 4000 ms if your Mac is slow - // or under memory pressure (observed gap can stretch past 2 s then). - bluebubbles: 2500, - }, - }, - }, - } - ``` - - - - - **Added latency for DM control commands.** With the flag on, DM control-command messages (like `Dump`, `Save`, etc.) now wait up to the debounce window before dispatching, in case a payload webhook is coming. Group-chat commands keep instant dispatch. - - **Merged output is bounded** - merged text caps at 4000 chars with an explicit `…[truncated]` marker; attachments cap at 20; source entries cap at 10 (first-plus-latest retained beyond that). Every source `messageId` still reaches inbound-dedupe so a later MessagePoller replay of any individual event is recognized as a duplicate. - - **Opt-in, per-channel.** Other channels (Telegram, WhatsApp, Slack, …) are unaffected. - - - - -### Scenarios and what the agent sees - -| User composes | Apple delivers | Flag off (default) | Flag on + 2500 ms window | -| ------------------------------------------------------------------ | ------------------------- | --------------------------------------- | ----------------------------------------------------------------------- | -| `Dump https://example.com` (one send) | 2 webhooks ~1 s apart | Two agent turns: "Dump" alone, then URL | One turn: merged text `Dump https://example.com` | -| `Save this 📎image.jpg caption` (attachment + text) | 2 webhooks | Two turns | One turn: text + image | -| `/status` (standalone command) | 1 webhook | Instant dispatch | **Wait up to window, then dispatch** | -| URL pasted alone | 1 webhook | Instant dispatch | Instant dispatch (only one entry in bucket) | -| Text + URL sent as two deliberate separate messages, minutes apart | 2 webhooks outside window | Two turns | Two turns (window expires between them) | -| Rapid flood (>10 small DMs inside window) | N webhooks | N turns | One turn, bounded output (first + latest, text/attachment caps applied) | - -### Split-send coalescing troubleshooting - -If the flag is on and split-sends still arrive as two turns, check each layer: - - - - ``` - grep coalesceSameSenderDms ~/.openclaw/openclaw.json - ``` - - Then `openclaw gateway restart` - the flag is read at debouncer-registry creation. - - - - Look at the BlueBubbles server log under `~/Library/Logs/bluebubbles-server/main.log`: - - ``` - grep -E "Dispatching event to webhook" main.log | tail -20 - ``` - - Measure the gap between the `"Dump"`-style text dispatch and the `"https://..."; Attachments:` dispatch that follows. Raise `messages.inbound.byChannel.bluebubbles` to comfortably cover that gap. - - - - Session event timestamps (`~/.openclaw/agents//sessions/*.jsonl`) reflect when the gateway hands a message to the agent, **not** when the webhook arrived. A queued-second message tagged `[Queued messages while agent was busy]` means the first turn was still running when the second webhook arrived - the coalesce bucket had already flushed. Tune the window against the BB server log, not the session log. - - - On smaller machines (8 GB), agent turns can take long enough that the coalesce bucket flushes before the reply completes, and the URL lands as a queued second turn. Check `memory_pressure` and `ps -o rss -p $(pgrep openclaw-gateway)`; if the gateway is over ~500 MB RSS and the compressor is active, close other heavy processes or bump to a larger host. - - - If the user tapped `Dump` as a **reply** to an existing URL-balloon (iMessage shows a "1 Reply" badge on the Dump bubble), the URL lives in `replyToBody`, not in a second webhook. Coalescing does not apply - that's a skill/prompt concern, not a debouncer concern. - - - -## Block streaming - -Control whether responses are sent as a single message or streamed in blocks: - -```json5 -{ - channels: { - bluebubbles: { - blockStreaming: true, // enable block streaming (off by default) - }, - }, -} -``` - -## Media + limits - -- Inbound attachments are downloaded and stored in the media cache. -- Media cap via `channels.bluebubbles.mediaMaxMb` for inbound and outbound media (default: 8 MB). -- Outbound text is chunked to `channels.bluebubbles.textChunkLimit` (default: 4000 chars). - -## Configuration reference - -Full configuration: [Configuration](/gateway/configuration) - - - - - `channels.bluebubbles.enabled`: Enable/disable the channel. - - `channels.bluebubbles.serverUrl`: BlueBubbles REST API base URL. - - `channels.bluebubbles.password`: API password. - - `channels.bluebubbles.webhookPath`: Webhook endpoint path (default: `/bluebubbles-webhook`). - - - - - `channels.bluebubbles.dmPolicy`: `pairing | allowlist | open | disabled` (default: `pairing`). - - `channels.bluebubbles.allowFrom`: DM allowlist (handles, emails, E.164 numbers, `chat_id:*`, `chat_guid:*`). - - `channels.bluebubbles.groupPolicy`: `open | allowlist | disabled` (default: `allowlist`). - - `channels.bluebubbles.groupAllowFrom`: Group sender allowlist. - - `channels.bluebubbles.enrichGroupParticipantsFromContacts`: On macOS, optionally enrich unnamed group participants from local Contacts after gating passes. Default: `false`. - - `channels.bluebubbles.groups`: Per-group config (`requireMention`, etc.). - - - - - `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`). - - `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `false`; required for streaming replies). - - `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000). - - `channels.bluebubbles.sendTimeoutMs`: Per-request timeout in ms for outbound text sends via `/api/v1/message/text` (default: 30000). Raise on macOS 26 setups where Private API iMessage sends can stall for 60+ seconds inside the iMessage framework; for example `45000` or `60000`. Probes, chat lookups, reactions, edits, and health checks currently keep the shorter 10s default; broadening coverage to reactions and edits is planned as a follow-up. Per-account override: `channels.bluebubbles.accounts..sendTimeoutMs`. - - `channels.bluebubbles.chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on blank lines (paragraph boundaries) before length chunking. - - - - - `channels.bluebubbles.mediaMaxMb`: Inbound/outbound media cap in MB (default: 8). - - `channels.bluebubbles.mediaLocalRoots`: Explicit allowlist of absolute local directories permitted for outbound local media paths. Local path sends are denied by default unless this is configured. Per-account override: `channels.bluebubbles.accounts..mediaLocalRoots`. - - `channels.bluebubbles.coalesceSameSenderDms`: Merge consecutive same-sender DM webhooks into one agent turn so Apple's text+URL split-send arrives as a single message (default: `false`). See [Coalescing split-send DMs](#coalescing-split-send-dms-command--url-in-one-composition) for scenarios, window tuning, and trade-offs. Widens the default inbound debounce window from 500 ms to 2500 ms when enabled without an explicit `messages.inbound.byChannel.bluebubbles`. - - `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables). - - `channels.bluebubbles.dmHistoryLimit`: DM history limit. - - `channels.bluebubbles.replyContextApiFallback`: When an inbound reply lands without `replyToBody`/`replyToSender` and the in-memory reply-context cache misses, fetch the original message from the BlueBubbles HTTP API as a best-effort fallback (default: `false`). Useful for multi-instance deployments sharing one BlueBubbles account, after process restarts, or after long-lived TTL/LRU cache eviction. The fetch is SSRF-guarded by the same policy as every other BlueBubbles client request, never throws, and populates the cache so subsequent replies amortize. Per-account override: `channels.bluebubbles.accounts..replyContextApiFallback`. A channel-level setting propagates to accounts that omit the flag. - - - - - `channels.bluebubbles.actions`: Enable/disable specific actions. - - `channels.bluebubbles.accounts`: Multi-account configuration. - - - - -Related global options: - -- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`). -- `messages.responsePrefix`. - -## Addressing / delivery targets - -Prefer `chat_guid` for stable routing: - -- `chat_guid:iMessage;-;+15555550123` (preferred for groups) -- `chat_id:123` -- `chat_identifier:...` -- Direct handles: `+15555550123`, `user@example.com` - - If a direct handle does not have an existing DM chat, OpenClaw will create one via `POST /api/v1/chat/new`. This requires the BlueBubbles Private API to be enabled. - -### iMessage vs SMS routing - -When the same handle has both an iMessage and an SMS chat on the Mac (for example a phone number that is iMessage-registered but has also received green-bubble fallbacks), OpenClaw prefers the iMessage chat and never silently downgrades to SMS. To force the SMS chat, use an explicit `sms:` target prefix (for example `sms:+15555550123`). Handles without a matching iMessage chat still send through whatever chat BlueBubbles reports. - -## Security - -- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. -- Keep the API password and webhook endpoint secret (treat them like credentials). -- There is no localhost bypass for BlueBubbles webhook auth. If you proxy webhook traffic, keep the BlueBubbles password on the request end-to-end. `gateway.trustedProxies` does not replace `channels.bluebubbles.password` here. See [Gateway security](/gateway/security#reverse-proxy-configuration). -- Enable HTTPS + firewall rules on the BlueBubbles server if exposing it outside your LAN. - -## Troubleshooting - -- If typing/read events stop working, check the BlueBubbles webhook logs and verify the gateway path matches `channels.bluebubbles.webhookPath`. -- Pairing codes expire after one hour; use `openclaw pairing list bluebubbles` and `openclaw pairing approve bluebubbles `. -- Reactions require the BlueBubbles private API (`POST /api/v1/message/react`); ensure the server version exposes it. -- Edit/unsend require macOS 13+ and a compatible BlueBubbles server version. On macOS 26 (Tahoe), edit is currently broken due to private API changes. -- Group icon updates can be flaky on macOS 26 (Tahoe): the API may return success but the new icon does not sync. -- OpenClaw auto-hides known-broken actions based on the BlueBubbles server's macOS version. If edit still appears on macOS 26 (Tahoe), disable it manually with `channels.bluebubbles.actions.edit=false`. -- `coalesceSameSenderDms` enabled but split-sends (e.g. `Dump` + URL) still arrive as two turns: see the [split-send coalescing troubleshooting](#split-send-coalescing-troubleshooting) checklist - common causes are too-tight debounce window, session-log timestamps misread as webhook arrival, or a reply-quote send (which uses `replyToBody`, not a second webhook). -- For status/health info: `openclaw status --all` or `openclaw status --deep`. - -For general channel workflow reference, see [Channels](/channels) and the [Plugins](/tools/plugin) guide. - -## Related - -- [Channel Routing](/channels/channel-routing) - session routing for messages -- [Channels Overview](/channels) - all supported channels -- [Groups](/channels/groups) - group chat behavior and mention gating -- [Pairing](/channels/pairing) - DM authentication and pairing flow -- [Security](/gateway/security) - access model and hardening diff --git a/docs/plugins/reference/bluebubbles.md b/docs/plugins/reference/bluebubbles.md deleted file mode 100644 index 1943e1eb20f4..000000000000 --- a/docs/plugins/reference/bluebubbles.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -summary: "Adds the BlueBubbles channel surface for sending and receiving OpenClaw messages." -read_when: - - You are installing, configuring, or auditing the bluebubbles plugin -title: "BlueBubbles plugin" ---- - -# BlueBubbles plugin - -Adds the BlueBubbles channel surface for sending and receiving OpenClaw messages. - -## Distribution - -- Package: `@openclaw/bluebubbles` -- Install route: npm; ClawHub - -## Surface - -channels: bluebubbles - -## Related docs - -- [bluebubbles](/channels/bluebubbles) diff --git a/extensions/bluebubbles/README.md b/extensions/bluebubbles/README.md deleted file mode 100644 index 927fd9c748e0..000000000000 --- a/extensions/bluebubbles/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# BlueBubbles extension (developer reference) - -This package contains the **BlueBubbles external channel plugin** for OpenClaw. - -If you’re looking for **how to use BlueBubbles as an agent/tool user**, see: - -- `skills/bluebubbles/SKILL.md` - -## Layout - -- Package entry: `index.ts`. -- Channel implementation: `src/channel.ts`. -- Webhook handling: `src/monitor.ts` (register per-account route via `registerPluginHttpRoute`). -- REST helpers: `src/send.ts` + `src/probe.ts`. -- Runtime bridge: `src/runtime.ts` (set via `api.runtime`). -- Catalog entry for setup selection: `src/channels/plugins/catalog.ts`. - -## Internal helpers (use these, not raw API calls) - -- `probeBlueBubbles` in `src/probe.ts` for health checks. -- `sendMessageBlueBubbles` in `src/send.ts` for text delivery. -- `resolveChatGuidForTarget` in `src/send.ts` for chat lookup. -- `sendBlueBubblesReaction` in `src/reactions.ts` for tapbacks. -- `sendBlueBubblesTyping` + `markBlueBubblesChatRead` in `src/chat.ts`. -- `downloadBlueBubblesAttachment` in `src/attachments.ts` for inbound media. -- `buildBlueBubblesApiUrl` + `blueBubblesFetchWithTimeout` in `src/types.ts` for shared REST plumbing. - -## Webhooks - -- BlueBubbles posts JSON to the gateway HTTP server. -- Normalize sender/chat IDs defensively (payloads vary by version). -- Skip messages marked as from self. -- Route into core reply pipeline via the plugin runtime (`api.runtime`) and `openclaw/plugin-sdk` helpers. -- For attachments/stickers, use `` placeholders when text is empty and attach media paths via `MediaUrl(s)` in the inbound context. - -## Config (core) - -- `channels.bluebubbles.serverUrl` (base URL), `channels.bluebubbles.password`, `channels.bluebubbles.webhookPath`. -- Action gating: `channels.bluebubbles.actions.reactions` (default true). - -## Message tool notes - -- **Reactions:** the `react` action requires a `target` (phone number or chat identifier) in addition to `messageId`. - Example: - `action=react target=+15551234567 messageId=ABC123 emoji=❤️` diff --git a/extensions/bluebubbles/api.ts b/extensions/bluebubbles/api.ts deleted file mode 100644 index ed1168c9a27e..000000000000 --- a/extensions/bluebubbles/api.ts +++ /dev/null @@ -1,19 +0,0 @@ -export { bluebubblesPlugin } from "./src/channel.js"; -export { bluebubblesSetupPlugin } from "./src/channel.setup.js"; -export { - matchBlueBubblesAcpConversation, - normalizeBlueBubblesAcpConversationId, - resolveBlueBubblesConversationIdFromTarget, - resolveBlueBubblesInboundConversationId, -} from "./src/conversation-id.js"; -export { - __testing, - createBlueBubblesConversationBindingManager, -} from "./src/conversation-bindings.js"; -export { collectBlueBubblesStatusIssues } from "./src/status-issues.js"; -export { - resolveBlueBubblesGroupRequireMention, - resolveBlueBubblesGroupToolPolicy, -} from "./src/group-policy.js"; -export { isAllowedBlueBubblesSender } from "./src/targets.js"; -export { BlueBubblesChannelConfigSchema, BlueBubblesConfigSchema } from "./src/config-schema.js"; diff --git a/extensions/bluebubbles/channel-config-api.ts b/extensions/bluebubbles/channel-config-api.ts deleted file mode 100644 index dcc35e318b88..000000000000 --- a/extensions/bluebubbles/channel-config-api.ts +++ /dev/null @@ -1 +0,0 @@ -export { BlueBubblesChannelConfigSchema, BlueBubblesConfigSchema } from "./src/config-schema.js"; diff --git a/extensions/bluebubbles/channel-plugin-api.ts b/extensions/bluebubbles/channel-plugin-api.ts deleted file mode 100644 index 5f2aa013bd1f..000000000000 --- a/extensions/bluebubbles/channel-plugin-api.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Keep bundled channel entry imports narrow so bootstrap/discovery paths do -// not drag setup-only BlueBubbles surfaces into lightweight channel plugin loads. -export { bluebubblesPlugin } from "./src/channel.js"; diff --git a/extensions/bluebubbles/contract-api.ts b/extensions/bluebubbles/contract-api.ts deleted file mode 100644 index a701f3c5e47c..000000000000 --- a/extensions/bluebubbles/contract-api.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { - collectRuntimeConfigAssignments, - secretTargetRegistryEntries, -} from "./src/secret-contract.js"; -export { - __testing as blueBubblesConversationBindingTesting, - createBlueBubblesConversationBindingManager, -} from "./src/conversation-bindings.js"; diff --git a/extensions/bluebubbles/doctor-contract-api.ts b/extensions/bluebubbles/doctor-contract-api.ts deleted file mode 100644 index a7a56f234421..000000000000 --- a/extensions/bluebubbles/doctor-contract-api.ts +++ /dev/null @@ -1 +0,0 @@ -export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js"; diff --git a/extensions/bluebubbles/index.ts b/extensions/bluebubbles/index.ts deleted file mode 100644 index d3d7aeb62455..000000000000 --- a/extensions/bluebubbles/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract"; - -export default defineBundledChannelEntry({ - id: "bluebubbles", - name: "BlueBubbles", - description: "BlueBubbles channel plugin (macOS app)", - importMetaUrl: import.meta.url, - plugin: { - specifier: "./channel-plugin-api.js", - exportName: "bluebubblesPlugin", - }, - secrets: { - specifier: "./secret-contract-api.js", - exportName: "channelSecrets", - }, - runtime: { - specifier: "./runtime-api.js", - exportName: "setBlueBubblesRuntime", - }, -}); diff --git a/extensions/bluebubbles/openclaw.plugin.json b/extensions/bluebubbles/openclaw.plugin.json deleted file mode 100644 index 9e6b15892bc0..000000000000 --- a/extensions/bluebubbles/openclaw.plugin.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "bluebubbles", - "activation": { - "onStartup": false - }, - "channels": ["bluebubbles"], - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": {} - } -} diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json deleted file mode 100644 index ed5d509fc2c7..000000000000 --- a/extensions/bluebubbles/package.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "name": "@openclaw/bluebubbles", - "version": "2026.5.6", - "description": "OpenClaw BlueBubbles channel plugin", - "repository": { - "type": "git", - "url": "https://github.com/openclaw/openclaw" - }, - "type": "module", - "devDependencies": { - "@openclaw/plugin-sdk": "workspace:*", - "openclaw": "workspace:*" - }, - "peerDependencies": { - "openclaw": ">=2026.5.6" - }, - "peerDependenciesMeta": { - "openclaw": { - "optional": true - } - }, - "openclaw": { - "extensions": [ - "./index.ts" - ], - "setupEntry": "./setup-entry.ts", - "channel": { - "id": "bluebubbles", - "label": "BlueBubbles", - "selectionLabel": "BlueBubbles (macOS app)", - "detailLabel": "BlueBubbles", - "docsPath": "/channels/bluebubbles", - "docsLabel": "bluebubbles", - "blurb": "iMessage via the BlueBubbles mac app + REST API.", - "aliases": [ - "bb" - ], - "preferOver": [ - "imessage" - ], - "systemImage": "bubble.left.and.text.bubble.right", - "order": 75, - "cliAddOptions": [ - { - "flags": "--webhook-path ", - "description": "BlueBubbles webhook path" - } - ] - }, - "install": { - "npmSpec": "@openclaw/bluebubbles", - "defaultChoice": "npm", - "minHostVersion": ">=2026.4.10" - }, - "compat": { - "pluginApi": ">=2026.5.6" - }, - "build": { - "openclawVersion": "2026.5.6" - }, - "release": { - "publishToClawHub": true, - "publishToNpm": true - } - } -} diff --git a/extensions/bluebubbles/runtime-api.ts b/extensions/bluebubbles/runtime-api.ts deleted file mode 100644 index 65cbb4489596..000000000000 --- a/extensions/bluebubbles/runtime-api.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - resolveBlueBubblesGroupRequireMention, - resolveBlueBubblesGroupToolPolicy, -} from "./src/group-policy.js"; -export { setBlueBubblesRuntime } from "./src/runtime.js"; diff --git a/extensions/bluebubbles/secret-contract-api.ts b/extensions/bluebubbles/secret-contract-api.ts deleted file mode 100644 index 9f44ef28569c..000000000000 --- a/extensions/bluebubbles/secret-contract-api.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - channelSecrets, - collectRuntimeConfigAssignments, - secretTargetRegistryEntries, -} from "./src/secret-contract.js"; diff --git a/extensions/bluebubbles/setup-entry.ts b/extensions/bluebubbles/setup-entry.ts deleted file mode 100644 index 32bd2147be75..000000000000 --- a/extensions/bluebubbles/setup-entry.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract"; - -export default defineBundledChannelSetupEntry({ - importMetaUrl: import.meta.url, - plugin: { - specifier: "./api.js", - exportName: "bluebubblesSetupPlugin", - }, - secrets: { - specifier: "./secret-contract-api.js", - exportName: "channelSecrets", - }, -}); diff --git a/extensions/bluebubbles/src/account-resolve.test.ts b/extensions/bluebubbles/src/account-resolve.test.ts deleted file mode 100644 index 7e77a5c7d230..000000000000 --- a/extensions/bluebubbles/src/account-resolve.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; - -describe("resolveBlueBubblesServerAccount", () => { - it("respects an explicit private-network opt-out for loopback server URLs", () => { - expect( - resolveBlueBubblesServerAccount({ - serverUrl: "http://127.0.0.1:1234", - password: "test-password", - cfg: { - channels: { - bluebubbles: { - network: { - dangerouslyAllowPrivateNetwork: false, - }, - }, - }, - }, - }), - ).toMatchObject({ - baseUrl: "http://127.0.0.1:1234", - password: "test-password", - allowPrivateNetwork: false, - }); - }); - - it("lets a legacy per-account opt-in override a channel-level canonical default", () => { - expect( - resolveBlueBubblesServerAccount({ - accountId: "personal", - cfg: { - channels: { - bluebubbles: { - network: { - dangerouslyAllowPrivateNetwork: false, - }, - accounts: { - personal: { - serverUrl: "http://127.0.0.1:1234", - password: "test-password", - allowPrivateNetwork: true, - }, - }, - }, - }, - }, - }), - ).toMatchObject({ - accountId: "personal", - baseUrl: "http://127.0.0.1:1234", - password: "test-password", - allowPrivateNetwork: true, - allowPrivateNetworkConfig: true, - }); - }); - - it("uses accounts.default config for the default BlueBubbles account", () => { - expect( - resolveBlueBubblesServerAccount({ - cfg: { - channels: { - bluebubbles: { - accounts: { - default: { - serverUrl: "http://127.0.0.1:1234", - password: "test-password", - allowPrivateNetwork: true, - }, - }, - }, - }, - }, - }), - ).toMatchObject({ - accountId: "default", - baseUrl: "http://127.0.0.1:1234", - password: "test-password", - allowPrivateNetwork: true, - allowPrivateNetworkConfig: true, - }); - }); - - describe("sendTimeoutMs", () => { - it("returns channel-level sendTimeoutMs when configured", () => { - expect( - resolveBlueBubblesServerAccount({ - serverUrl: "http://localhost:1234", - password: "test-password", - cfg: { - channels: { - bluebubbles: { - sendTimeoutMs: 45_000, - }, - }, - }, - }), - ).toMatchObject({ sendTimeoutMs: 45_000 }); - }); - - it("returns per-account sendTimeoutMs when configured", () => { - expect( - resolveBlueBubblesServerAccount({ - accountId: "personal", - cfg: { - channels: { - bluebubbles: { - accounts: { - personal: { - serverUrl: "http://localhost:1234", - password: "test-password", - sendTimeoutMs: 60_000, - }, - }, - }, - }, - }, - }), - ).toMatchObject({ sendTimeoutMs: 60_000 }); - }); - - it("returns undefined sendTimeoutMs when unconfigured (use DEFAULT_SEND_TIMEOUT_MS downstream)", () => { - const resolved = resolveBlueBubblesServerAccount({ - serverUrl: "http://localhost:1234", - password: "test-password", - cfg: {}, - }); - expect(resolved.sendTimeoutMs).toBeUndefined(); - }); - - it("ignores non-positive / non-integer sendTimeoutMs values", () => { - for (const bad of [0, -1, 1.5, Number.NaN]) { - const resolved = resolveBlueBubblesServerAccount({ - serverUrl: "http://localhost:1234", - password: "test-password", - cfg: { - channels: { - bluebubbles: { - // runtime might receive a malformed value via raw config; the - // resolver must drop it so downstream falls back to the default. - sendTimeoutMs: bad as unknown as number, - }, - }, - }, - }); - expect(resolved.sendTimeoutMs).toBeUndefined(); - } - }); - }); -}); diff --git a/extensions/bluebubbles/src/account-resolve.ts b/extensions/bluebubbles/src/account-resolve.ts deleted file mode 100644 index ed0e8c6326f3..000000000000 --- a/extensions/bluebubbles/src/account-resolve.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { - resolveBlueBubblesAccount, - resolveBlueBubblesEffectiveAllowPrivateNetwork, - resolveBlueBubblesPrivateNetworkConfigValue, -} from "./accounts.js"; -import type { OpenClawConfig } from "./runtime-api.js"; -import { normalizeResolvedSecretInputString } from "./secret-input.js"; - -type BlueBubblesAccountResolveOpts = { - serverUrl?: string; - password?: string; - accountId?: string; - cfg?: OpenClawConfig; -}; - -export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolveOpts): { - baseUrl: string; - password: string; - accountId: string; - allowPrivateNetwork: boolean; - allowPrivateNetworkConfig?: boolean; - /** - * Per-account send timeout from `channels.bluebubbles.sendTimeoutMs` (or - * `accounts..sendTimeoutMs`). Only returned when the caller configured - * a positive integer; `undefined` means "fall back to DEFAULT_SEND_TIMEOUT_MS". - * (#67486) - */ - sendTimeoutMs?: number; -} { - const account = resolveBlueBubblesAccount({ - cfg: params.cfg ?? {}, - accountId: params.accountId, - }); - const baseUrl = - normalizeResolvedSecretInputString({ - value: params.serverUrl, - path: "channels.bluebubbles.serverUrl", - }) || - normalizeResolvedSecretInputString({ - value: account.config.serverUrl, - path: `channels.bluebubbles.accounts.${account.accountId}.serverUrl`, - }); - const password = - normalizeResolvedSecretInputString({ - value: params.password, - path: "channels.bluebubbles.password", - }) || - normalizeResolvedSecretInputString({ - value: account.config.password, - path: `channels.bluebubbles.accounts.${account.accountId}.password`, - }); - if (!baseUrl) { - throw new Error("BlueBubbles serverUrl is required"); - } - if (!password) { - throw new Error("BlueBubbles password is required"); - } - - const rawSendTimeoutMs = account.config.sendTimeoutMs; - const sendTimeoutMs = - typeof rawSendTimeoutMs === "number" && - Number.isInteger(rawSendTimeoutMs) && - rawSendTimeoutMs > 0 - ? rawSendTimeoutMs - : undefined; - return { - baseUrl, - password, - accountId: account.accountId, - allowPrivateNetwork: resolveBlueBubblesEffectiveAllowPrivateNetwork({ - baseUrl, - config: account.config, - }), - allowPrivateNetworkConfig: resolveBlueBubblesPrivateNetworkConfigValue(account.config), - sendTimeoutMs, - }; -} diff --git a/extensions/bluebubbles/src/accounts-normalization.ts b/extensions/bluebubbles/src/accounts-normalization.ts deleted file mode 100644 index bd57b1c824b3..000000000000 --- a/extensions/bluebubbles/src/accounts-normalization.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime"; -import { normalizeBlueBubblesServerUrl } from "./types.js"; - -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - -export function normalizeBlueBubblesPrivateNetworkAliases( - config: T, -): T { - const record = asRecord(config); - if (!record) { - return config; - } - const network = asRecord(record.network); - const canonicalValue = - typeof network?.dangerouslyAllowPrivateNetwork === "boolean" - ? network.dangerouslyAllowPrivateNetwork - : typeof network?.allowPrivateNetwork === "boolean" - ? network.allowPrivateNetwork - : typeof record.dangerouslyAllowPrivateNetwork === "boolean" - ? record.dangerouslyAllowPrivateNetwork - : typeof record.allowPrivateNetwork === "boolean" - ? record.allowPrivateNetwork - : undefined; - - if (canonicalValue === undefined) { - return config; - } - - const { - allowPrivateNetwork: _legacyFlatAllow, - dangerouslyAllowPrivateNetwork: _legacyFlatDanger, - ...rest - } = record; - const { - allowPrivateNetwork: _legacyNetworkAllow, - dangerouslyAllowPrivateNetwork: _legacyNetworkDanger, - ...restNetwork - } = network ?? {}; - - return { - ...rest, - network: { - ...restNetwork, - dangerouslyAllowPrivateNetwork: canonicalValue, - }, - } as T; -} - -export function normalizeBlueBubblesAccountsMap( - accounts: Record | undefined, -): Record | undefined { - if (!accounts) { - return undefined; - } - return Object.fromEntries( - Object.entries(accounts).map(([accountKey, accountConfig]) => [ - accountKey, - normalizeBlueBubblesPrivateNetworkAliases(accountConfig), - ]), - ); -} - -export function resolveBlueBubblesPrivateNetworkConfigValue( - config: object | null | undefined, -): boolean | undefined { - const record = asRecord(config); - if (!record) { - return undefined; - } - const network = asRecord(record.network); - if (typeof network?.dangerouslyAllowPrivateNetwork === "boolean") { - return network.dangerouslyAllowPrivateNetwork; - } - if (typeof network?.allowPrivateNetwork === "boolean") { - return network.allowPrivateNetwork; - } - if (typeof record.dangerouslyAllowPrivateNetwork === "boolean") { - return record.dangerouslyAllowPrivateNetwork; - } - if (typeof record.allowPrivateNetwork === "boolean") { - return record.allowPrivateNetwork; - } - return undefined; -} - -export function resolveBlueBubblesEffectiveAllowPrivateNetworkFromConfig(params: { - baseUrl?: string; - config?: object | null; -}): boolean { - const configuredValue = resolveBlueBubblesPrivateNetworkConfigValue(params.config); - if (configuredValue !== undefined) { - return configuredValue; - } - if (!params.baseUrl) { - return false; - } - try { - const hostname = new URL(normalizeBlueBubblesServerUrl(params.baseUrl)).hostname.trim(); - return Boolean(hostname) && isBlockedHostnameOrIp(hostname); - } catch { - return false; - } -} diff --git a/extensions/bluebubbles/src/accounts.ts b/extensions/bluebubbles/src/accounts.ts deleted file mode 100644 index 85ef4d61aa7f..000000000000 --- a/extensions/bluebubbles/src/accounts.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { - createAccountListHelpers, - normalizeAccountId, - resolveMergedAccountConfig, -} from "openclaw/plugin-sdk/account-resolution"; -import { resolveChannelStreamingChunkMode } from "openclaw/plugin-sdk/channel-streaming"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; -import { - normalizeBlueBubblesAccountsMap, - normalizeBlueBubblesPrivateNetworkAliases, - resolveBlueBubblesEffectiveAllowPrivateNetworkFromConfig, - resolveBlueBubblesPrivateNetworkConfigValue as resolveBlueBubblesPrivateNetworkConfigValueFromRecord, -} from "./accounts-normalization.js"; -import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js"; -import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js"; - -export type ResolvedBlueBubblesAccount = { - accountId: string; - enabled: boolean; - name?: string; - config: BlueBubblesAccountConfig; - configured: boolean; - baseUrl?: string; -}; - -const { - listAccountIds: listBlueBubblesAccountIds, - resolveDefaultAccountId: resolveDefaultBlueBubblesAccountId, -} = createAccountListHelpers("bluebubbles"); -export { listBlueBubblesAccountIds, resolveDefaultBlueBubblesAccountId }; - -function mergeBlueBubblesAccountConfig( - cfg: OpenClawConfig, - accountId: string, -): BlueBubblesAccountConfig { - const channelConfig = normalizeBlueBubblesPrivateNetworkAliases( - cfg.channels?.bluebubbles as BlueBubblesAccountConfig | undefined, - ); - const accounts = normalizeBlueBubblesAccountsMap( - cfg.channels?.bluebubbles?.accounts as - | Record> - | undefined, - ); - const merged = resolveMergedAccountConfig({ - channelConfig, - accounts, - accountId, - omitKeys: ["defaultAccount"], - normalizeAccountId, - nestedObjectKeys: ["network", "catchup"], - }); - return { - ...merged, - chunkMode: resolveChannelStreamingChunkMode(merged) ?? merged.chunkMode ?? "length", - }; -} - -export function resolveBlueBubblesAccount(params: { - cfg: OpenClawConfig; - accountId?: string | null; -}): ResolvedBlueBubblesAccount { - const accountId = normalizeAccountId( - params.accountId ?? resolveDefaultBlueBubblesAccountId(params.cfg), - ); - const baseEnabled = params.cfg.channels?.bluebubbles?.enabled; - const merged = mergeBlueBubblesAccountConfig(params.cfg, accountId); - const accountEnabled = merged.enabled !== false; - const serverUrl = normalizeSecretInputString(merged.serverUrl); - const _password = normalizeSecretInputString(merged.password); - const configured = Boolean(serverUrl && hasConfiguredSecretInput(merged.password)); - const baseUrl = serverUrl ? normalizeBlueBubblesServerUrl(serverUrl) : undefined; - return { - accountId, - enabled: baseEnabled !== false && accountEnabled, - name: normalizeOptionalString(merged.name), - config: merged, - configured, - baseUrl, - }; -} - -export function resolveBlueBubblesPrivateNetworkConfigValue( - config: BlueBubblesAccountConfig | null | undefined, -): boolean | undefined { - return resolveBlueBubblesPrivateNetworkConfigValueFromRecord(config); -} - -export function resolveBlueBubblesEffectiveAllowPrivateNetwork(params: { - baseUrl?: string; - config?: BlueBubblesAccountConfig | null; -}): boolean { - return resolveBlueBubblesEffectiveAllowPrivateNetworkFromConfig(params); -} diff --git a/extensions/bluebubbles/src/actions-api.ts b/extensions/bluebubbles/src/actions-api.ts deleted file mode 100644 index 0b7e09f6fd5f..000000000000 --- a/extensions/bluebubbles/src/actions-api.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { BLUEBUBBLES_ACTION_NAMES, BLUEBUBBLES_ACTIONS } from "./actions-contract.js"; -export type { - ChannelMessageActionAdapter, - ChannelMessageActionName, -} from "openclaw/plugin-sdk/channel-contract"; diff --git a/extensions/bluebubbles/src/actions-contract.ts b/extensions/bluebubbles/src/actions-contract.ts deleted file mode 100644 index bf6a77e78984..000000000000 --- a/extensions/bluebubbles/src/actions-contract.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const BLUEBUBBLES_ACTIONS = { - react: { gate: "reactions" }, - edit: { gate: "edit", unsupportedOnMacOS26: true }, - unsend: { gate: "unsend" }, - reply: { gate: "reply" }, - sendWithEffect: { gate: "sendWithEffect" }, - renameGroup: { gate: "renameGroup", groupOnly: true }, - setGroupIcon: { gate: "setGroupIcon", groupOnly: true }, - addParticipant: { gate: "addParticipant", groupOnly: true }, - removeParticipant: { gate: "removeParticipant", groupOnly: true }, - leaveGroup: { gate: "leaveGroup", groupOnly: true }, - sendAttachment: { gate: "sendAttachment" }, -} as const; - -type BlueBubblesActionSpecs = typeof BLUEBUBBLES_ACTIONS; - -export const BLUEBUBBLES_ACTION_NAMES = Object.keys(BLUEBUBBLES_ACTIONS) as Array< - keyof BlueBubblesActionSpecs ->; diff --git a/extensions/bluebubbles/src/actions.runtime.ts b/extensions/bluebubbles/src/actions.runtime.ts deleted file mode 100644 index 667e9eec36d2..000000000000 --- a/extensions/bluebubbles/src/actions.runtime.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { sendBlueBubblesAttachment as sendBlueBubblesAttachmentImpl } from "./attachments.js"; -import { - addBlueBubblesParticipant as addBlueBubblesParticipantImpl, - editBlueBubblesMessage as editBlueBubblesMessageImpl, - leaveBlueBubblesChat as leaveBlueBubblesChatImpl, - removeBlueBubblesParticipant as removeBlueBubblesParticipantImpl, - renameBlueBubblesChat as renameBlueBubblesChatImpl, - setGroupIconBlueBubbles as setGroupIconBlueBubblesImpl, - unsendBlueBubblesMessage as unsendBlueBubblesMessageImpl, -} from "./chat.js"; -import { resolveBlueBubblesMessageId as resolveBlueBubblesMessageIdImpl } from "./monitor-reply-cache.js"; -import { sendBlueBubblesReaction as sendBlueBubblesReactionImpl } from "./reactions.js"; -import { - resolveChatGuidForTarget as resolveChatGuidForTargetImpl, - sendMessageBlueBubbles as sendMessageBlueBubblesImpl, -} from "./send.js"; - -export const blueBubblesActionsRuntime = { - sendBlueBubblesAttachment: sendBlueBubblesAttachmentImpl, - addBlueBubblesParticipant: addBlueBubblesParticipantImpl, - editBlueBubblesMessage: editBlueBubblesMessageImpl, - leaveBlueBubblesChat: leaveBlueBubblesChatImpl, - removeBlueBubblesParticipant: removeBlueBubblesParticipantImpl, - renameBlueBubblesChat: renameBlueBubblesChatImpl, - setGroupIconBlueBubbles: setGroupIconBlueBubblesImpl, - unsendBlueBubblesMessage: unsendBlueBubblesMessageImpl, - resolveBlueBubblesMessageId: resolveBlueBubblesMessageIdImpl, - sendBlueBubblesReaction: sendBlueBubblesReactionImpl, - resolveChatGuidForTarget: resolveChatGuidForTargetImpl, - sendMessageBlueBubbles: sendMessageBlueBubblesImpl, -}; diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts deleted file mode 100644 index d065a58df028..000000000000 --- a/extensions/bluebubbles/src/actions.test.ts +++ /dev/null @@ -1,764 +0,0 @@ -import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; -import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { sendBlueBubblesAttachment } from "./attachments.js"; -import { editBlueBubblesMessage, setGroupIconBlueBubbles } from "./chat.js"; -import { resolveBlueBubblesMessageId } from "./monitor-reply-cache.js"; -import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; -import { sendBlueBubblesReaction } from "./reactions.js"; -import type { OpenClawConfig } from "./runtime-api.js"; -import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; - -vi.mock("./accounts.js", async () => { - const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js"); - return createBlueBubblesAccountsMockModule(); -}); - -vi.mock("./reactions.js", () => ({ - sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("./send.js", () => ({ - resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"), - sendMessageBlueBubbles: vi.fn().mockResolvedValue({ messageId: "msg-123" }), -})); - -vi.mock("./chat.js", () => ({ - editBlueBubblesMessage: vi.fn().mockResolvedValue(undefined), - unsendBlueBubblesMessage: vi.fn().mockResolvedValue(undefined), - renameBlueBubblesChat: vi.fn().mockResolvedValue(undefined), - setGroupIconBlueBubbles: vi.fn().mockResolvedValue(undefined), - addBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined), - removeBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined), - leaveBlueBubblesChat: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("./attachments.js", () => ({ - sendBlueBubblesAttachment: vi.fn().mockResolvedValue({ messageId: "att-msg-123" }), -})); - -vi.mock("./monitor-reply-cache.js", () => ({ - resolveBlueBubblesMessageId: vi.fn((id: string) => id), -})); - -vi.mock("./probe.js", () => ({ - isMacOS26OrHigher: vi.fn().mockReturnValue(false), - getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null), -})); - -const { bluebubblesMessageActions } = await importFreshModule( - import.meta.url, - "./actions.js?actions-test", -); - -function requireDefined(value: T | undefined, name: string): T { - if (value === undefined) { - throw new Error(`${name} is not registered`); - } - return value; -} - -describe("bluebubblesMessageActions", () => { - const describeMessageTool = requireDefined( - bluebubblesMessageActions.describeMessageTool, - "describeMessageTool", - ); - const supportsAction = requireDefined(bluebubblesMessageActions.supportsAction, "supportsAction"); - const extractToolSend = requireDefined( - bluebubblesMessageActions.extractToolSend, - "extractToolSend", - ); - const handleAction = requireDefined(bluebubblesMessageActions.handleAction, "handleAction"); - const callHandleAction = (ctx: Omit[0], "channel">) => - handleAction({ channel: "bluebubbles", ...ctx }); - const blueBubblesConfig = (): OpenClawConfig => ({ - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }); - const runReactAction = async (params: Record) => { - return await callHandleAction({ - action: "react", - params, - cfg: blueBubblesConfig(), - accountId: null, - }); - }; - - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null); - }); - - afterAll(() => { - vi.doUnmock("./accounts.js"); - vi.doUnmock("./reactions.js"); - vi.doUnmock("./send.js"); - vi.doUnmock("./chat.js"); - vi.doUnmock("./attachments.js"); - vi.doUnmock("./monitor-reply-cache.js"); - vi.doUnmock("./probe.js"); - vi.resetModules(); - }); - - describe("describeMessageTool", () => { - it("returns empty array when account is not enabled", () => { - const cfg: OpenClawConfig = { - channels: { bluebubbles: { enabled: false } }, - }; - const actions = describeMessageTool({ cfg })?.actions ?? []; - expect(actions).toEqual([]); - }); - - it("returns empty array when account is not configured", () => { - const cfg: OpenClawConfig = { - channels: { bluebubbles: { enabled: true } }, - }; - const actions = describeMessageTool({ cfg })?.actions ?? []; - expect(actions).toEqual([]); - }); - - it("returns react action when enabled and configured", () => { - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - enabled: true, - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - const actions = describeMessageTool({ cfg })?.actions ?? []; - expect(actions).toContain("react"); - }); - - it("excludes react action when reactions are gated off", () => { - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - enabled: true, - serverUrl: "http://localhost:1234", - password: "test-password", - actions: { reactions: false }, - }, - }, - }; - const actions = describeMessageTool({ cfg })?.actions ?? []; - expect(actions).not.toContain("react"); - // Other actions should still be present - expect(actions).toContain("edit"); - expect(actions).toContain("unsend"); - }); - - it("honors account-scoped action gates during discovery", () => { - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - actions: { reactions: false }, - accounts: { - work: { - serverUrl: "http://localhost:5678", - password: "work-password", - actions: { reactions: true }, - }, - }, - }, - }, - }; - - expect(describeMessageTool({ cfg, accountId: "default" })?.actions).not.toContain("react"); - expect(describeMessageTool({ cfg, accountId: "work" })?.actions).toContain("react"); - }); - - it("hides private-api actions when private API is disabled", () => { - vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false); - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - enabled: true, - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - const actions = describeMessageTool({ cfg })?.actions ?? []; - expect(actions).toContain("upload-file"); - expect(actions).not.toContain("sendAttachment"); - expect(actions).not.toContain("react"); - expect(actions).not.toContain("reply"); - expect(actions).not.toContain("sendWithEffect"); - expect(actions).not.toContain("edit"); - expect(actions).not.toContain("unsend"); - expect(actions).not.toContain("renameGroup"); - expect(actions).not.toContain("setGroupIcon"); - expect(actions).not.toContain("addParticipant"); - expect(actions).not.toContain("removeParticipant"); - expect(actions).not.toContain("leaveGroup"); - }); - }); - - describe("supportsAction", () => { - it("returns true for react action", () => { - expect(supportsAction({ action: "react" })).toBe(true); - }); - - it("returns true for all supported actions", () => { - expect(supportsAction({ action: "edit" })).toBe(true); - expect(supportsAction({ action: "unsend" })).toBe(true); - expect(supportsAction({ action: "reply" })).toBe(true); - expect(supportsAction({ action: "sendWithEffect" })).toBe(true); - expect(supportsAction({ action: "renameGroup" })).toBe(true); - expect(supportsAction({ action: "setGroupIcon" })).toBe(true); - expect(supportsAction({ action: "addParticipant" })).toBe(true); - expect(supportsAction({ action: "removeParticipant" })).toBe(true); - expect(supportsAction({ action: "leaveGroup" })).toBe(true); - expect(supportsAction({ action: "sendAttachment" })).toBe(true); - expect(supportsAction({ action: "upload-file" })).toBe(true); - }); - - it("returns false for unsupported actions", () => { - expect(supportsAction({ action: "delete" as never })).toBe(false); - expect(supportsAction({ action: "unknown" as never })).toBe(false); - }); - }); - - describe("extractToolSend", () => { - it("extracts send params from sendMessage action", () => { - const result = extractToolSend({ - args: { - action: "sendMessage", - to: "+15551234567", - accountId: "test-account", - }, - }); - expect(result).toEqual({ - to: "+15551234567", - accountId: "test-account", - }); - }); - - it("returns null for non-sendMessage action", () => { - const result = extractToolSend({ - args: { action: "react", to: "+15551234567" }, - }); - expect(result).toBeNull(); - }); - - it("returns null when to is missing", () => { - const result = extractToolSend({ - args: { action: "sendMessage" }, - }); - expect(result).toBeNull(); - }); - }); - - describe("handleAction", () => { - it("maps upload-file to the attachment runtime using canonical naming", async () => { - const result = await callHandleAction({ - action: "upload-file", - params: { - to: "+15551234567", - filename: "photo.png", - buffer: Buffer.from("img").toString("base64"), - message: "caption", - contentType: "image/png", - }, - cfg: blueBubblesConfig(), - accountId: null, - }); - - expect(sendBlueBubblesAttachment).toHaveBeenCalledWith( - expect.objectContaining({ - to: "+15551234567", - filename: "photo.png", - caption: "caption", - contentType: "image/png", - }), - ); - expect(result).toMatchObject({ - details: { - ok: true, - messageId: "att-msg-123", - }, - }); - }); - - it("throws for unsupported actions", async () => { - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - await expect( - callHandleAction({ - action: "unknownAction" as never, - params: {}, - cfg, - accountId: null, - }), - ).rejects.toThrow("is not supported"); - }); - - it("throws when emoji is missing for react action", async () => { - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - await expect( - callHandleAction({ - action: "react", - params: { messageId: "msg-123" }, - cfg, - accountId: null, - }), - ).rejects.toThrow(/emoji/i); - }); - - it("throws a private-api error for private-only actions when disabled", async () => { - vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false); - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - await expect( - callHandleAction({ - action: "react", - params: { emoji: "❤️", messageId: "msg-123", chatGuid: "iMessage;-;+15551234567" }, - cfg, - accountId: null, - }), - ).rejects.toThrow("requires Private API"); - }); - - it("throws when messageId is missing", async () => { - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - await expect( - callHandleAction({ - action: "react", - params: { emoji: "❤️" }, - cfg, - accountId: null, - }), - ).rejects.toThrow("messageId"); - }); - - it("throws when chatGuid cannot be resolved", async () => { - vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce(null); - - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - await expect( - callHandleAction({ - action: "react", - params: { emoji: "❤️", messageId: "msg-123", to: "+15551234567" }, - cfg, - accountId: null, - }), - ).rejects.toThrow("chatGuid not found"); - }); - - it("sends reaction successfully with chatGuid", async () => { - const result = await runReactAction({ - emoji: "❤️", - messageId: "msg-123", - chatGuid: "iMessage;-;+15551234567", - }); - - expect(sendBlueBubblesReaction).toHaveBeenCalledWith( - expect.objectContaining({ - chatGuid: "iMessage;-;+15551234567", - messageGuid: "msg-123", - emoji: "❤️", - }), - ); - // jsonResult returns { content: [...], details: payload } - expect(result).toMatchObject({ - details: { ok: true, added: "❤️" }, - }); - }); - - it("sends reaction removal successfully", async () => { - const result = await runReactAction({ - emoji: "❤️", - messageId: "msg-123", - chatGuid: "iMessage;-;+15551234567", - remove: true, - }); - - expect(sendBlueBubblesReaction).toHaveBeenCalledWith( - expect.objectContaining({ - remove: true, - }), - ); - // jsonResult returns { content: [...], details: payload } - expect(result).toMatchObject({ - details: { ok: true, removed: true }, - }); - }); - - it("resolves chatGuid from to parameter", async () => { - vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15559876543"); - - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - await callHandleAction({ - action: "react", - params: { - emoji: "👍", - messageId: "msg-456", - to: "+15559876543", - }, - cfg, - accountId: null, - }); - - expect(resolveChatGuidForTarget).toHaveBeenCalled(); - expect(sendBlueBubblesReaction).toHaveBeenCalledWith( - expect.objectContaining({ - chatGuid: "iMessage;-;+15559876543", - }), - ); - }); - - it("passes partIndex when provided", async () => { - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - await callHandleAction({ - action: "react", - params: { - emoji: "😂", - messageId: "msg-789", - chatGuid: "iMessage;-;chat-guid", - partIndex: 2, - }, - cfg, - accountId: null, - }); - - expect(sendBlueBubblesReaction).toHaveBeenCalledWith( - expect.objectContaining({ - partIndex: 2, - }), - ); - }); - - it("uses toolContext currentChannelId when no explicit target is provided", async () => { - vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15550001111"); - - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - await callHandleAction({ - 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 () => { - vi.mocked(resolveBlueBubblesMessageId).mockReturnValueOnce("resolved-uuid"); - - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - - await callHandleAction({ - action: "react", - params: { - emoji: "❤️", - messageId: "1", - chatGuid: "iMessage;-;+15551234567", - }, - cfg, - accountId: null, - }); - - expect(resolveBlueBubblesMessageId).toHaveBeenCalledWith( - "1", - expect.objectContaining({ - requireKnownShortId: true, - chatContext: expect.objectContaining({ - chatGuid: "iMessage;-;+15551234567", - }), - }), - ); - expect(sendBlueBubblesReaction).toHaveBeenCalledWith( - expect.objectContaining({ - messageGuid: "resolved-uuid", - }), - ); - }); - - it("propagates short-id errors from the resolver", async () => { - vi.mocked(resolveBlueBubblesMessageId).mockImplementationOnce(() => { - throw new Error("short id expired"); - }); - - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - - await expect( - callHandleAction({ - 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 cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - - await callHandleAction({ - action: "edit", - params: { messageId: "msg-123", message: "updated" }, - cfg, - accountId: null, - }); - - expect(editBlueBubblesMessage).toHaveBeenCalledWith( - "msg-123", - "updated", - expect.objectContaining({ cfg, accountId: undefined }), - ); - }); - - it("accepts message/target aliases for sendWithEffect", async () => { - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - - const result = await callHandleAction({ - action: "sendWithEffect", - params: { - message: "peekaboo", - target: "+15551234567", - effect: "invisible ink", - }, - cfg, - accountId: null, - }); - - expect(sendMessageBlueBubbles).toHaveBeenCalledWith( - "+15551234567", - "peekaboo", - expect.objectContaining({ effectId: "invisible ink" }), - ); - expect(result).toMatchObject({ - details: { ok: true, messageId: "msg-123", effect: "invisible ink" }, - }); - }); - - it("passes asVoice through sendAttachment", async () => { - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - - const base64Buffer = Buffer.from("voice").toString("base64"); - - await callHandleAction({ - action: "sendAttachment", - params: { - to: "+15551234567", - filename: "voice.mp3", - buffer: base64Buffer, - contentType: "audio/mpeg", - asVoice: true, - }, - cfg, - accountId: null, - }); - - expect(sendBlueBubblesAttachment).toHaveBeenCalledWith( - expect.objectContaining({ - filename: "voice.mp3", - contentType: "audio/mpeg", - asVoice: true, - }), - ); - }); - - it("throws when buffer is missing for setGroupIcon", async () => { - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - - await expect( - callHandleAction({ - action: "setGroupIcon", - params: { chatGuid: "iMessage;-;chat-guid" }, - cfg, - accountId: null, - }), - ).rejects.toThrow(/requires an image/i); - }); - - it("sets group icon successfully with chatGuid and buffer", async () => { - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - - // Base64 encode a simple test buffer - const testBuffer = Buffer.from("fake-image-data"); - const base64Buffer = testBuffer.toString("base64"); - - const result = await callHandleAction({ - action: "setGroupIcon", - params: { - chatGuid: "iMessage;-;chat-guid", - buffer: base64Buffer, - filename: "group-icon.png", - contentType: "image/png", - }, - cfg, - accountId: null, - }); - - expect(setGroupIconBlueBubbles).toHaveBeenCalledWith( - "iMessage;-;chat-guid", - expect.any(Uint8Array), - "group-icon.png", - expect.objectContaining({ contentType: "image/png" }), - ); - expect(result).toMatchObject({ - details: { ok: true, chatGuid: "iMessage;-;chat-guid", iconSet: true }, - }); - }); - - it("uses default filename when not provided for setGroupIcon", async () => { - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - - const base64Buffer = Buffer.from("test").toString("base64"); - - await callHandleAction({ - action: "setGroupIcon", - params: { - chatGuid: "iMessage;-;chat-guid", - buffer: base64Buffer, - }, - cfg, - accountId: null, - }); - - expect(setGroupIconBlueBubbles).toHaveBeenCalledWith( - "iMessage;-;chat-guid", - expect.any(Uint8Array), - "icon.png", - expect.anything(), - ); - }); - }); -}); diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts deleted file mode 100644 index 2b32e6edf397..000000000000 --- a/extensions/bluebubbles/src/actions.ts +++ /dev/null @@ -1,531 +0,0 @@ -import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; -import { - createActionGate, - jsonResult, - readNumberParam, - readReactionParams, - readStringParam, -} from "openclaw/plugin-sdk/channel-actions"; -import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; -import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; -import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime"; -import { extractToolSend } from "openclaw/plugin-sdk/tool-send"; -import { resolveBlueBubblesAccount } from "./accounts.js"; -import { - BLUEBUBBLES_ACTION_NAMES, - BLUEBUBBLES_ACTIONS, - type ChannelMessageActionAdapter, - type ChannelMessageActionName, -} from "./actions-api.js"; -import type { BlueBubblesChatContext } from "./monitor-reply-cache.js"; -import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js"; -import { normalizeSecretInputString } from "./secret-input.js"; -import { - buildBlueBubblesChatContextFromTarget, - normalizeBlueBubblesHandle, - normalizeBlueBubblesMessagingTarget, - parseBlueBubblesTarget, -} from "./targets.js"; -import type { BlueBubblesSendTarget } from "./types.js"; - -const loadBlueBubblesActionsRuntime = createLazyRuntimeNamedExport( - () => import("./actions.runtime.js"), - "blueBubblesActionsRuntime", -); - -const providerId = "bluebubbles"; - -function mapTarget(raw: string): BlueBubblesSendTarget { - const parsed = parseBlueBubblesTarget(raw); - if (parsed.kind === "chat_guid") { - return { kind: "chat_guid", chatGuid: parsed.chatGuid }; - } - if (parsed.kind === "chat_id") { - return { kind: "chat_id", chatId: parsed.chatId }; - } - if (parsed.kind === "chat_identifier") { - return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier }; - } - return { - kind: "handle", - address: normalizeBlueBubblesHandle(parsed.to), - service: parsed.service, - }; -} - -/** - * Collect any chat-identifying hints the action caller supplied, so short - * message id resolution can reject cross-chat collisions. The order of - * precedence mirrors resolveChatGuid: explicit chat* params first, then the - * `to`/`target` param, then the current session channel as a last resort. - */ -function buildChatContextFromActionParams(params: { - actionParams: Record; - currentChannelId?: string; -}): BlueBubblesChatContext { - const explicitChatGuid = readStringParam(params.actionParams, "chatGuid")?.trim(); - const explicitChatIdentifier = readStringParam(params.actionParams, "chatIdentifier")?.trim(); - const explicitChatId = readNumberParam(params.actionParams, "chatId", { integer: true }); - const rawTarget = - readStringParam(params.actionParams, "to") ?? - readStringParam(params.actionParams, "target") ?? - params.currentChannelId ?? - undefined; - const targetContext = buildBlueBubblesChatContextFromTarget(rawTarget); - return { - chatGuid: explicitChatGuid || targetContext.chatGuid, - chatIdentifier: explicitChatIdentifier || targetContext.chatIdentifier, - chatId: typeof explicitChatId === "number" ? explicitChatId : targetContext.chatId, - }; -} - -function readMessageText(params: Record): string | undefined { - return readStringParam(params, "text") ?? readStringParam(params, "message"); -} - -/** Supported action names for BlueBubbles */ -const SUPPORTED_ACTIONS = new Set([ - ...BLUEBUBBLES_ACTION_NAMES, - "upload-file", -]); -const PRIVATE_API_ACTIONS = new Set([ - "react", - "edit", - "unsend", - "reply", - "sendWithEffect", - "renameGroup", - "setGroupIcon", - "addParticipant", - "removeParticipant", - "leaveGroup", -]); - -export const bluebubblesMessageActions: ChannelMessageActionAdapter = { - describeMessageTool: ({ cfg, accountId, currentChannelId }) => { - const account = resolveBlueBubblesAccount({ cfg, accountId }); - if (!account.enabled || !account.configured) { - return null; - } - const gate = createActionGate(account.config.actions); - const actions = new Set(); - const macOS26 = isMacOS26OrHigher(account.accountId); - const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId); - for (const action of BLUEBUBBLES_ACTION_NAMES) { - const spec = BLUEBUBBLES_ACTIONS[action]; - if (!spec?.gate) { - continue; - } - if (privateApiStatus === false && PRIVATE_API_ACTIONS.has(action)) { - continue; - } - if ("unsupportedOnMacOS26" in spec && spec.unsupportedOnMacOS26 && macOS26) { - continue; - } - if (gate(spec.gate)) { - actions.add(action); - } - } - const normalizedTarget = currentChannelId - ? normalizeBlueBubblesMessagingTarget(currentChannelId) - : undefined; - const lowered = normalizeOptionalLowercaseString(normalizedTarget) ?? ""; - const isGroupTarget = - lowered.startsWith("chat_guid:") || - lowered.startsWith("chat_id:") || - lowered.startsWith("chat_identifier:") || - lowered.startsWith("group:"); - if (!isGroupTarget) { - for (const action of BLUEBUBBLES_ACTION_NAMES) { - if ("groupOnly" in BLUEBUBBLES_ACTIONS[action] && BLUEBUBBLES_ACTIONS[action].groupOnly) { - actions.delete(action); - } - } - } - if (actions.delete("sendAttachment")) { - actions.add("upload-file"); - } - return { actions: Array.from(actions) }; - }, - supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action), - extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"), - handleAction: async ({ action, params, cfg, accountId, toolContext }) => { - const runtime = await loadBlueBubblesActionsRuntime(); - const account = resolveBlueBubblesAccount({ - cfg: cfg, - accountId: accountId ?? undefined, - }); - const baseUrl = normalizeSecretInputString(account.config.serverUrl); - const password = normalizeSecretInputString(account.config.password); - const opts = { cfg: cfg, accountId: accountId ?? undefined }; - const assertPrivateApiEnabled = () => { - if (getCachedBlueBubblesPrivateApiStatus(account.accountId) === false) { - throw new Error( - `BlueBubbles ${action} requires Private API, but it is disabled on the BlueBubbles server.`, - ); - } - }; - - // Helper to resolve chatGuid from various params or session context - const resolveChatGuid = async (): Promise => { - const chatGuid = readStringParam(params, "chatGuid"); - if (chatGuid?.trim()) { - return chatGuid.trim(); - } - - const chatIdentifier = readStringParam(params, "chatIdentifier"); - const chatId = readNumberParam(params, "chatId", { integer: true }); - const to = readStringParam(params, "to"); - // Fall back to session context if no explicit target provided - const contextTarget = toolContext?.currentChannelId?.trim(); - - const target = chatIdentifier?.trim() - ? ({ - kind: "chat_identifier", - chatIdentifier: chatIdentifier.trim(), - } as BlueBubblesSendTarget) - : typeof chatId === "number" - ? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget) - : to - ? mapTarget(to) - : contextTarget - ? mapTarget(contextTarget) - : null; - - if (!target) { - throw new Error(`BlueBubbles ${action} requires chatGuid, chatIdentifier, chatId, or to.`); - } - if (!baseUrl || !password) { - throw new Error(`BlueBubbles ${action} requires serverUrl and password.`); - } - - const resolved = await runtime.resolveChatGuidForTarget({ - baseUrl, - password, - target, - allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config), - }); - if (!resolved) { - throw new Error(`BlueBubbles ${action} failed: chatGuid not found for target.`); - } - return resolved; - }; - - // Handle react action - if (action === "react") { - assertPrivateApiEnabled(); - const { emoji, remove, isEmpty } = readReactionParams(params, { - removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.", - }); - if (isEmpty && !remove) { - throw new Error( - "BlueBubbles react requires emoji parameter. Use action=react with emoji= and messageId=.", - ); - } - const rawMessageId = readStringParam(params, "messageId"); - if (!rawMessageId) { - throw new Error( - "BlueBubbles react requires messageId parameter (the message ID to react to). " + - "Use action=react with messageId=, emoji=, and to/chatGuid to identify the chat.", - ); - } - // Resolve short ID (e.g., "1", "2") to full UUID, scoped to the chat the - // caller is acting on so a short ID from a different chat cannot be - // silently accepted (see cross-chat guard in resolveBlueBubblesMessageId). - const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, { - requireKnownShortId: true, - chatContext: buildChatContextFromActionParams({ - actionParams: params, - currentChannelId: toolContext?.currentChannelId, - }), - }); - const partIndex = readNumberParam(params, "partIndex", { integer: true }); - const resolvedChatGuid = await resolveChatGuid(); - - await runtime.sendBlueBubblesReaction({ - chatGuid: resolvedChatGuid, - messageGuid: messageId, - emoji, - remove: remove || undefined, - partIndex: typeof partIndex === "number" ? partIndex : undefined, - opts, - }); - - return jsonResult({ ok: true, ...(remove ? { removed: true } : { added: emoji }) }); - } - - // Handle edit action - if (action === "edit") { - assertPrivateApiEnabled(); - // Edit is not supported on macOS 26+ - if (isMacOS26OrHigher(accountId ?? undefined)) { - throw new Error( - "BlueBubbles edit is not supported on macOS 26 or higher. " + - "Apple removed the ability to edit iMessages in this version.", - ); - } - const rawMessageId = readStringParam(params, "messageId"); - const newText = - readStringParam(params, "text") ?? - readStringParam(params, "newText") ?? - readStringParam(params, "message"); - if (!rawMessageId || !newText) { - const missing: string[] = []; - if (!rawMessageId) { - missing.push("messageId (the message ID to edit)"); - } - if (!newText) { - missing.push("text (the new message content)"); - } - throw new Error( - `BlueBubbles edit requires: ${missing.join(", ")}. ` + - `Use action=edit with messageId=, text=.`, - ); - } - // Resolve short ID (e.g., "1", "2") to full UUID, scoped to the chat - // the caller is acting on (cross-chat guard). - const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, { - requireKnownShortId: true, - chatContext: buildChatContextFromActionParams({ - actionParams: params, - currentChannelId: toolContext?.currentChannelId, - }), - }); - const partIndex = readNumberParam(params, "partIndex", { integer: true }); - const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage"); - - await runtime.editBlueBubblesMessage(messageId, newText, { - ...opts, - partIndex: typeof partIndex === "number" ? partIndex : undefined, - backwardsCompatMessage: backwardsCompatMessage ?? undefined, - }); - - return jsonResult({ ok: true, edited: rawMessageId }); - } - - // Handle unsend action - if (action === "unsend") { - assertPrivateApiEnabled(); - const rawMessageId = readStringParam(params, "messageId"); - if (!rawMessageId) { - throw new Error( - "BlueBubbles unsend requires messageId parameter (the message ID to unsend). " + - "Use action=unsend with messageId=.", - ); - } - // Resolve short ID (e.g., "1", "2") to full UUID, scoped to the chat - // the caller is acting on (cross-chat guard). - const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, { - requireKnownShortId: true, - chatContext: buildChatContextFromActionParams({ - actionParams: params, - currentChannelId: toolContext?.currentChannelId, - }), - }); - const partIndex = readNumberParam(params, "partIndex", { integer: true }); - - await runtime.unsendBlueBubblesMessage(messageId, { - ...opts, - partIndex: typeof partIndex === "number" ? partIndex : undefined, - }); - - return jsonResult({ ok: true, unsent: rawMessageId }); - } - - // Handle reply action - if (action === "reply") { - assertPrivateApiEnabled(); - const rawMessageId = readStringParam(params, "messageId"); - const text = readMessageText(params); - const to = readStringParam(params, "to") ?? readStringParam(params, "target"); - if (!rawMessageId || !text || !to) { - const missing: string[] = []; - if (!rawMessageId) { - missing.push("messageId (the message ID to reply to)"); - } - if (!text) { - missing.push("text or message (the reply message content)"); - } - if (!to) { - missing.push("to or target (the chat target)"); - } - throw new Error( - `BlueBubbles reply requires: ${missing.join(", ")}. ` + - `Use action=reply with messageId=, message=, target=.`, - ); - } - // Resolve short ID (e.g., "1", "2") to full UUID, scoped to the chat - // the caller is acting on (cross-chat guard). - const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, { - requireKnownShortId: true, - chatContext: buildChatContextFromActionParams({ - actionParams: params, - currentChannelId: toolContext?.currentChannelId, - }), - }); - const partIndex = readNumberParam(params, "partIndex", { integer: true }); - - const result = await runtime.sendMessageBlueBubbles(to, text, { - ...opts, - replyToMessageGuid: messageId, - replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined, - }); - - return jsonResult({ ok: true, messageId: result.messageId, repliedTo: rawMessageId }); - } - - // Handle sendWithEffect action - if (action === "sendWithEffect") { - assertPrivateApiEnabled(); - const text = readMessageText(params); - const to = readStringParam(params, "to") ?? readStringParam(params, "target"); - const effectId = readStringParam(params, "effectId") ?? readStringParam(params, "effect"); - if (!text || !to || !effectId) { - const missing: string[] = []; - if (!text) { - missing.push("text or message (the message content)"); - } - if (!to) { - missing.push("to or target (the chat target)"); - } - if (!effectId) { - missing.push( - "effectId or effect (e.g., slam, loud, gentle, invisible-ink, confetti, lasers, fireworks, balloons, heart)", - ); - } - throw new Error( - `BlueBubbles sendWithEffect requires: ${missing.join(", ")}. ` + - `Use action=sendWithEffect with message=, target=, effectId=.`, - ); - } - - const result = await runtime.sendMessageBlueBubbles(to, text, { - ...opts, - effectId, - }); - - return jsonResult({ ok: true, messageId: result.messageId, effect: effectId }); - } - - // Handle renameGroup action - if (action === "renameGroup") { - assertPrivateApiEnabled(); - const resolvedChatGuid = await resolveChatGuid(); - const displayName = readStringParam(params, "displayName") ?? readStringParam(params, "name"); - if (!displayName) { - throw new Error("BlueBubbles renameGroup requires displayName or name parameter."); - } - - await runtime.renameBlueBubblesChat(resolvedChatGuid, displayName, opts); - - return jsonResult({ ok: true, renamed: resolvedChatGuid, displayName }); - } - - // Handle setGroupIcon action - if (action === "setGroupIcon") { - assertPrivateApiEnabled(); - const resolvedChatGuid = await resolveChatGuid(); - const base64Buffer = readStringParam(params, "buffer"); - const filename = - readStringParam(params, "filename") ?? readStringParam(params, "name") ?? "icon.png"; - const contentType = - readStringParam(params, "contentType") ?? readStringParam(params, "mimeType"); - - if (!base64Buffer) { - throw new Error( - "BlueBubbles setGroupIcon requires an image. " + - "Use action=setGroupIcon with media= or path= to set the group icon.", - ); - } - - // Decode base64 to buffer - const buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0)); - - await runtime.setGroupIconBlueBubbles(resolvedChatGuid, buffer, filename, { - ...opts, - contentType: contentType ?? undefined, - }); - - return jsonResult({ ok: true, chatGuid: resolvedChatGuid, iconSet: true }); - } - - // Handle addParticipant action - if (action === "addParticipant") { - assertPrivateApiEnabled(); - const resolvedChatGuid = await resolveChatGuid(); - const address = readStringParam(params, "address") ?? readStringParam(params, "participant"); - if (!address) { - throw new Error("BlueBubbles addParticipant requires address or participant parameter."); - } - - await runtime.addBlueBubblesParticipant(resolvedChatGuid, address, opts); - - return jsonResult({ ok: true, added: address, chatGuid: resolvedChatGuid }); - } - - // Handle removeParticipant action - if (action === "removeParticipant") { - assertPrivateApiEnabled(); - const resolvedChatGuid = await resolveChatGuid(); - const address = readStringParam(params, "address") ?? readStringParam(params, "participant"); - if (!address) { - throw new Error("BlueBubbles removeParticipant requires address or participant parameter."); - } - - await runtime.removeBlueBubblesParticipant(resolvedChatGuid, address, opts); - - return jsonResult({ ok: true, removed: address, chatGuid: resolvedChatGuid }); - } - - // Handle leaveGroup action - if (action === "leaveGroup") { - assertPrivateApiEnabled(); - const resolvedChatGuid = await resolveChatGuid(); - - await runtime.leaveBlueBubblesChat(resolvedChatGuid, opts); - - return jsonResult({ ok: true, left: resolvedChatGuid }); - } - - // Handle sendAttachment action (legacy) and upload-file (canonical) - if (action === "sendAttachment" || action === "upload-file") { - const to = readStringParam(params, "to", { required: true }); - const filename = readStringParam(params, "filename", { required: true }); - const caption = readStringParam(params, "caption") ?? readStringParam(params, "message"); - const contentType = - readStringParam(params, "contentType") ?? readStringParam(params, "mimeType"); - const asVoice = readBooleanParam(params, "asVoice"); - - // Buffer can come from params.buffer (base64) or params.path (file path) - const base64Buffer = readStringParam(params, "buffer"); - const filePath = readStringParam(params, "path") ?? readStringParam(params, "filePath"); - - let buffer: Uint8Array; - if (base64Buffer) { - // Decode base64 to buffer - buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0)); - } else if (filePath) { - // Read file from path (will be handled by caller providing buffer) - throw new Error( - `BlueBubbles ${action}: filePath not supported in action, provide buffer as base64.`, - ); - } else { - throw new Error(`BlueBubbles ${action} requires buffer (base64) parameter.`); - } - - const result = await runtime.sendBlueBubblesAttachment({ - to, - buffer, - filename, - contentType: contentType ?? undefined, - caption: caption ?? undefined, - asVoice: asVoice ?? undefined, - opts, - }); - - return jsonResult({ ok: true, messageId: result.messageId }); - } - - throw new Error(`Action ${action} is not supported for provider ${providerId}.`); - }, -}; diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts deleted file mode 100644 index d4c09c48fb81..000000000000 --- a/extensions/bluebubbles/src/attachments.test.ts +++ /dev/null @@ -1,836 +0,0 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/core"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import "./test-mocks.js"; -import { - downloadBlueBubblesAttachment, - fetchBlueBubblesMessageAttachments, - sendBlueBubblesAttachment, -} from "./attachments.js"; -import { fetchBlueBubblesServerInfo, getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; -import { setBlueBubblesRuntime } from "./runtime.js"; -import { - BLUE_BUBBLES_PRIVATE_API_STATUS, - installBlueBubblesFetchTestHooks, - mockBlueBubblesPrivateApiStatus, - mockBlueBubblesPrivateApiStatusOnce, -} from "./test-harness.js"; -import { - createBlueBubblesFetchRemoteMediaMock, - createBlueBubblesRuntimeStub, -} from "./test-helpers.js"; -import type { BlueBubblesAttachment } from "./types.js"; - -const mockFetch = vi.fn(); -const fetchServerInfoMock = vi.mocked(fetchBlueBubblesServerInfo); -const fetchRemoteMediaMock = createBlueBubblesFetchRemoteMediaMock({ - createHttpError: async ({ response, url }) => { - const text = await response.text().catch(() => "unknown"); - return new Error(`Failed to fetch media from ${url}: HTTP ${response.status}; body: ${text}`); - }, -}); - -installBlueBubblesFetchTestHooks({ - mockFetch, - privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus), -}); - -const runtimeStub = createBlueBubblesRuntimeStub(fetchRemoteMediaMock); - -describe("downloadBlueBubblesAttachment", () => { - beforeEach(() => { - fetchRemoteMediaMock.mockClear(); - mockFetch.mockReset(); - setBlueBubblesRuntime(runtimeStub); - }); - - async function expectAttachmentTooLarge(params: { bufferBytes: number; maxBytes?: number }) { - const largeBuffer = new Uint8Array(params.bufferBytes); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers(), - arrayBuffer: () => Promise.resolve(largeBuffer.buffer), - }); - - const attachment: BlueBubblesAttachment = { guid: "att-large" }; - await expect( - downloadBlueBubblesAttachment(attachment, { - serverUrl: "http://localhost:1234", - password: "test", - ...(params.maxBytes === undefined ? {} : { maxBytes: params.maxBytes }), - }), - ).rejects.toThrow("too large"); - } - - function mockSuccessfulAttachmentDownload(buffer = new Uint8Array([1])) { - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers(), - arrayBuffer: () => Promise.resolve(buffer.buffer), - }); - return buffer; - } - - it("throws when guid is missing", async () => { - const attachment: BlueBubblesAttachment = {}; - await expect( - downloadBlueBubblesAttachment(attachment, { - serverUrl: "http://localhost:1234", - password: "test-password", - }), - ).rejects.toThrow("guid is required"); - }); - - it("throws when guid is empty string", async () => { - const attachment: BlueBubblesAttachment = { guid: " " }; - await expect( - downloadBlueBubblesAttachment(attachment, { - serverUrl: "http://localhost:1234", - password: "test-password", - }), - ).rejects.toThrow("guid is required"); - }); - - it("throws when serverUrl is missing", async () => { - const attachment: BlueBubblesAttachment = { guid: "att-123" }; - await expect(downloadBlueBubblesAttachment(attachment, {})).rejects.toThrow( - "serverUrl is required", - ); - }); - - it("throws when password is missing", async () => { - const attachment: BlueBubblesAttachment = { guid: "att-123" }; - await expect( - downloadBlueBubblesAttachment(attachment, { - serverUrl: "http://localhost:1234", - }), - ).rejects.toThrow("password is required"); - }); - - it("downloads attachment successfully", async () => { - const mockBuffer = new Uint8Array([1, 2, 3, 4]); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers({ "content-type": "image/png" }), - arrayBuffer: () => Promise.resolve(mockBuffer.buffer), - }); - - const attachment: BlueBubblesAttachment = { guid: "att-123" }; - const result = await downloadBlueBubblesAttachment(attachment, { - serverUrl: "http://localhost:1234", - password: "test-password", - }); - - expect(result.buffer).toEqual(mockBuffer); - expect(result.contentType).toBe("image/png"); - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining("/api/v1/attachment/att-123/download"), - expect.objectContaining({ method: "GET" }), - ); - }); - - it("includes password in URL query", async () => { - const mockBuffer = new Uint8Array([1, 2, 3, 4]); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers({ "content-type": "image/jpeg" }), - arrayBuffer: () => Promise.resolve(mockBuffer.buffer), - }); - - const attachment: BlueBubblesAttachment = { guid: "att-456" }; - await downloadBlueBubblesAttachment(attachment, { - serverUrl: "http://localhost:1234", - password: "my-secret-password", - }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("password=my-secret-password"); - }); - - it("encodes guid in URL", async () => { - mockSuccessfulAttachmentDownload(); - - const attachment: BlueBubblesAttachment = { guid: "att/with/special chars" }; - await downloadBlueBubblesAttachment(attachment, { - serverUrl: "http://localhost:1234", - password: "test", - }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("att%2Fwith%2Fspecial%20chars"); - }); - - it("throws on non-ok response", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404, - text: () => Promise.resolve("Attachment not found"), - }); - - const attachment: BlueBubblesAttachment = { guid: "att-missing" }; - await expect( - downloadBlueBubblesAttachment(attachment, { - serverUrl: "http://localhost:1234", - password: "test", - }), - ).rejects.toThrow("Attachment not found"); - }); - - it("throws when attachment exceeds max bytes", async () => { - await expectAttachmentTooLarge({ - bufferBytes: 10 * 1024 * 1024, - maxBytes: 5 * 1024 * 1024, - }); - }); - - it("uses default max bytes when not specified", async () => { - await expectAttachmentTooLarge({ bufferBytes: 9 * 1024 * 1024 }); - }); - - it("uses attachment mimeType as fallback when response has no content-type", async () => { - const mockBuffer = new Uint8Array([1, 2, 3]); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers(), - arrayBuffer: () => Promise.resolve(mockBuffer.buffer), - }); - - const attachment: BlueBubblesAttachment = { - guid: "att-789", - mimeType: "video/mp4", - }; - const result = await downloadBlueBubblesAttachment(attachment, { - serverUrl: "http://localhost:1234", - password: "test", - }); - - expect(result.contentType).toBe("video/mp4"); - }); - - it("prefers response content-type over attachment mimeType", async () => { - const mockBuffer = new Uint8Array([1, 2, 3]); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers({ "content-type": "image/webp" }), - arrayBuffer: () => Promise.resolve(mockBuffer.buffer), - }); - - const attachment: BlueBubblesAttachment = { - guid: "att-xyz", - mimeType: "image/png", - }; - const result = await downloadBlueBubblesAttachment(attachment, { - serverUrl: "http://localhost:1234", - password: "test", - }); - - expect(result.contentType).toBe("image/webp"); - }); - - it("resolves credentials from config when opts not provided", async () => { - mockSuccessfulAttachmentDownload(); - - const attachment: BlueBubblesAttachment = { guid: "att-config" }; - const result = await downloadBlueBubblesAttachment(attachment, { - cfg: { - channels: { - bluebubbles: { - serverUrl: "http://config-server:5678", - password: "config-password", - }, - }, - }, - }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("config-server:5678"); - expect(calledUrl).toContain("password=config-password"); - expect(result.buffer).toEqual(new Uint8Array([1])); - }); - - it("passes ssrfPolicy with allowPrivateNetwork when config enables it", async () => { - mockSuccessfulAttachmentDownload(); - - const attachment: BlueBubblesAttachment = { guid: "att-ssrf" }; - await downloadBlueBubblesAttachment(attachment, { - cfg: { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test", - network: { - dangerouslyAllowPrivateNetwork: true, - }, - }, - }, - }, - }); - - const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record; - expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowPrivateNetwork: true }); - }); - - it("auto-enables private-network fetches for loopback serverUrl when allowPrivateNetwork is not set", async () => { - mockSuccessfulAttachmentDownload(); - - const attachment: BlueBubblesAttachment = { guid: "att-no-ssrf" }; - await downloadBlueBubblesAttachment(attachment, { - serverUrl: "http://localhost:1234", - password: "test", - cfg: { channels: { bluebubbles: {} } }, - }); - - const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record; - expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowPrivateNetwork: true }); - }); - - it("auto-enables private-network fetches for private IP serverUrl when allowPrivateNetwork is not set", async () => { - mockSuccessfulAttachmentDownload(); - - const attachment: BlueBubblesAttachment = { guid: "att-private-ip" }; - await downloadBlueBubblesAttachment(attachment, { - serverUrl: "http://192.168.1.5:1234", - password: "test", - cfg: { channels: { bluebubbles: {} } }, - }); - - const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record; - expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowPrivateNetwork: true }); - }); - - it("respects an explicit private-network opt-out for loopback serverUrl", async () => { - mockSuccessfulAttachmentDownload(); - - const attachment: BlueBubblesAttachment = { guid: "att-opt-out" }; - await downloadBlueBubblesAttachment(attachment, { - serverUrl: "http://localhost:1234", - password: "test", - cfg: { - channels: { - bluebubbles: { - network: { - dangerouslyAllowPrivateNetwork: false, - }, - }, - }, - }, - }); - - // Default-deny policy via the guard, NOT unguarded fetch. Aisle #68234 - // flagged the previous `undefined` fallback as a real SSRF bypass because - // `blueBubblesFetchWithTimeout` treats `undefined` as "skip the SSRF - // guard entirely", exactly when the user asked us to block private nets. - const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record; - expect(fetchMediaArgs.ssrfPolicy).toEqual({}); - }); - - it("allowlists public serverUrl hostname when allowPrivateNetwork is not set", async () => { - mockSuccessfulAttachmentDownload(); - - const attachment: BlueBubblesAttachment = { guid: "att-public-host" }; - await downloadBlueBubblesAttachment(attachment, { - serverUrl: "https://bluebubbles.example.com:1234", - password: "test", - }); - - const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record; - expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowedHostnames: ["bluebubbles.example.com"] }); - }); - - it("keeps public serverUrl hostname pinning when private-network access is explicitly disabled", async () => { - mockSuccessfulAttachmentDownload(); - - const attachment: BlueBubblesAttachment = { guid: "att-public-host-opt-out" }; - await downloadBlueBubblesAttachment(attachment, { - serverUrl: "https://bluebubbles.example.com:1234", - password: "test", - cfg: { - channels: { - bluebubbles: { - network: { - dangerouslyAllowPrivateNetwork: false, - }, - }, - }, - }, - }); - - const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record; - expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowedHostnames: ["bluebubbles.example.com"] }); - }); -}); - -describe("sendBlueBubblesAttachment", () => { - beforeEach(() => { - vi.stubGlobal("fetch", mockFetch); - mockFetch.mockReset(); - fetchRemoteMediaMock.mockClear(); - fetchServerInfoMock.mockReset(); - fetchServerInfoMock.mockResolvedValue(null); - setBlueBubblesRuntime(runtimeStub); - vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset(); - mockBlueBubblesPrivateApiStatus( - vi.mocked(getCachedBlueBubblesPrivateApiStatus), - BLUE_BUBBLES_PRIVATE_API_STATUS.unknown, - ); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - function decodeBody(body: Uint8Array) { - return Buffer.from(body).toString("utf8"); - } - - function expectVoiceAttachmentBody() { - const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; - const bodyText = decodeBody(body); - expect(bodyText).toContain('name="isAudioMessage"'); - expect(bodyText).toContain("true"); - return bodyText; - } - - it("marks voice memos when asVoice is true and mp3 is provided", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ messageId: "msg-1" })), - }); - - await sendBlueBubblesAttachment({ - to: "chat_guid:iMessage;-;+15551234567", - buffer: new Uint8Array([1, 2, 3]), - filename: "voice.mp3", - contentType: "audio/mpeg", - asVoice: true, - opts: { serverUrl: "http://localhost:1234", password: "test" }, - }); - - const bodyText = expectVoiceAttachmentBody(); - expect(bodyText).toContain('filename="voice.mp3"'); - }); - - it("normalizes mp3 filenames for voice memos", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ messageId: "msg-2" })), - }); - - await sendBlueBubblesAttachment({ - to: "chat_guid:iMessage;-;+15551234567", - buffer: new Uint8Array([1, 2, 3]), - filename: "voice", - contentType: "audio/mpeg", - asVoice: true, - opts: { serverUrl: "http://localhost:1234", password: "test" }, - }); - - const bodyText = expectVoiceAttachmentBody(); - expect(bodyText).toContain('filename="voice.mp3"'); - expect(bodyText).toContain('name="voice.mp3"'); - }); - - it("throws when asVoice is true but media is not audio", async () => { - await expect( - sendBlueBubblesAttachment({ - to: "chat_guid:iMessage;-;+15551234567", - buffer: new Uint8Array([1, 2, 3]), - filename: "image.png", - contentType: "image/png", - asVoice: true, - opts: { serverUrl: "http://localhost:1234", password: "test" }, - }), - ).rejects.toThrow("voice messages require audio"); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it("throws when asVoice is true but audio is not mp3 or caf", async () => { - await expect( - sendBlueBubblesAttachment({ - to: "chat_guid:iMessage;-;+15551234567", - buffer: new Uint8Array([1, 2, 3]), - filename: "voice.wav", - contentType: "audio/wav", - asVoice: true, - opts: { serverUrl: "http://localhost:1234", password: "test" }, - }), - ).rejects.toThrow("require mp3 or caf"); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it("sanitizes filenames before sending", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ messageId: "msg-3" })), - }); - - await sendBlueBubblesAttachment({ - to: "chat_guid:iMessage;-;+15551234567", - buffer: new Uint8Array([1, 2, 3]), - filename: "../evil.mp3", - contentType: "audio/mpeg", - opts: { serverUrl: "http://localhost:1234", password: "test" }, - }); - - const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; - const bodyText = decodeBody(body); - expect(bodyText).toContain('filename="evil.mp3"'); - expect(bodyText).toContain('name="evil.mp3"'); - }); - - it("downgrades attachment reply threading when private API is disabled", async () => { - mockBlueBubblesPrivateApiStatusOnce( - vi.mocked(getCachedBlueBubblesPrivateApiStatus), - BLUE_BUBBLES_PRIVATE_API_STATUS.disabled, - ); - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ messageId: "msg-4" })), - }); - - await sendBlueBubblesAttachment({ - to: "chat_guid:iMessage;-;+15551234567", - buffer: new Uint8Array([1, 2, 3]), - filename: "photo.jpg", - contentType: "image/jpeg", - replyToMessageGuid: "reply-guid-123", - opts: { serverUrl: "http://localhost:1234", password: "test" }, - }); - - const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; - const bodyText = decodeBody(body); - expect(bodyText).not.toContain('name="method"'); - expect(bodyText).not.toContain('name="selectedMessageGuid"'); - expect(bodyText).not.toContain('name="partIndex"'); - }); - - it("warns and downgrades attachment reply threading when private API status is unknown", async () => { - const runtimeLog = vi.fn(); - setBlueBubblesRuntime({ - ...runtimeStub, - log: runtimeLog, - } as unknown as PluginRuntime); - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ messageId: "msg-5" })), - }); - - await sendBlueBubblesAttachment({ - to: "chat_guid:iMessage;-;+15551234567", - buffer: new Uint8Array([1, 2, 3]), - filename: "photo.jpg", - contentType: "image/jpeg", - replyToMessageGuid: "reply-guid-unknown", - opts: { serverUrl: "http://localhost:1234", password: "test" }, - }); - - expect(runtimeLog).toHaveBeenCalledTimes(1); - expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown"); - const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; - const bodyText = decodeBody(body); - expect(bodyText).not.toContain('name="selectedMessageGuid"'); - expect(bodyText).not.toContain('name="partIndex"'); - }); - - it("auto-creates a new chat when sending to a phone number with no existing chat", async () => { - // First call: resolveChatGuidForTarget queries chats, returns empty (no match) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }); - // Second call: createChatForHandle creates new chat - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - data: { chatGuid: "iMessage;-;+15559876543", guid: "iMessage;-;+15559876543" }, - }), - ), - }); - // Third call: actual attachment send - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ data: { guid: "attach-msg-1" } })), - }); - - const result = await sendBlueBubblesAttachment({ - to: "+15559876543", - buffer: new Uint8Array([1, 2, 3]), - filename: "photo.jpg", - contentType: "image/jpeg", - opts: { serverUrl: "http://localhost:1234", password: "test" }, - }); - - expect(result.messageId).toBe("attach-msg-1"); - // Verify chat creation was called - const createCallBody = JSON.parse(mockFetch.mock.calls[1][1].body); - expect(createCallBody.addresses).toEqual(["+15559876543"]); - // Verify attachment was sent to the newly created chat - const attachBody = mockFetch.mock.calls[2][1]?.body as Uint8Array; - const attachText = decodeBody(attachBody); - expect(attachText).toContain("iMessage;-;+15559876543"); - }); - - it("retries chatGuid resolution after creating a chat with no returned guid", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }); - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ data: {} })), - }); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: [{ guid: "iMessage;-;+15557654321" }] }), - }); - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ data: { guid: "attach-msg-2" } })), - }); - - const result = await sendBlueBubblesAttachment({ - to: "+15557654321", - buffer: new Uint8Array([4, 5, 6]), - filename: "photo.jpg", - contentType: "image/jpeg", - opts: { serverUrl: "http://localhost:1234", password: "test" }, - }); - - expect(result.messageId).toBe("attach-msg-2"); - const createCallBody = JSON.parse(mockFetch.mock.calls[1][1].body); - expect(createCallBody.addresses).toEqual(["+15557654321"]); - const attachBody = mockFetch.mock.calls[3][1]?.body as Uint8Array; - const attachText = decodeBody(attachBody); - expect(attachText).toContain("iMessage;-;+15557654321"); - }); - - describe("lazy private API refresh (#43764)", () => { - const privateApiStatusMock = vi.mocked(getCachedBlueBubblesPrivateApiStatus); - - it("refreshes cache when expired and reply threading is requested", async () => { - privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(true); - fetchServerInfoMock.mockResolvedValueOnce({ private_api: true }); - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-refreshed" } })), - }); - - const result = await sendBlueBubblesAttachment({ - to: "chat_guid:iMessage;-;+15551234567", - buffer: new Uint8Array([1, 2, 3]), - filename: "photo.jpg", - contentType: "image/jpeg", - replyToMessageGuid: "reply-guid-456", - opts: { serverUrl: "http://localhost:1234", password: "test" }, - }); - - expect(result.messageId).toBe("msg-refreshed"); - expect(fetchServerInfoMock).toHaveBeenCalledTimes(1); - const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; - const bodyText = decodeBody(body); - expect(bodyText).toContain('name="method"'); - expect(bodyText).toContain("private-api"); - expect(bodyText).toContain('name="selectedMessageGuid"'); - }); - - it("does not refresh when cache is populated (cache hit)", async () => { - mockBlueBubblesPrivateApiStatusOnce( - privateApiStatusMock, - BLUE_BUBBLES_PRIVATE_API_STATUS.enabled, - ); - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-cached" } })), - }); - - await sendBlueBubblesAttachment({ - to: "chat_guid:iMessage;-;+15551234567", - buffer: new Uint8Array([1, 2, 3]), - filename: "photo.jpg", - contentType: "image/jpeg", - replyToMessageGuid: "reply-guid-123", - opts: { serverUrl: "http://localhost:1234", password: "test" }, - }); - - expect(fetchServerInfoMock).not.toHaveBeenCalled(); - }); - - it("degrades gracefully when refresh fails", async () => { - fetchServerInfoMock.mockRejectedValueOnce(new Error("network error")); - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-degraded" } })), - }); - - const runtimeLog = vi.fn(); - setBlueBubblesRuntime({ - ...runtimeStub, - log: runtimeLog, - } as unknown as PluginRuntime); - - const result = await sendBlueBubblesAttachment({ - to: "chat_guid:iMessage;-;+15551234567", - buffer: new Uint8Array([1, 2, 3]), - filename: "photo.jpg", - contentType: "image/jpeg", - replyToMessageGuid: "reply-guid-789", - opts: { serverUrl: "http://localhost:1234", password: "test" }, - }); - - expect(result.messageId).toBe("msg-degraded"); - expect(fetchServerInfoMock).toHaveBeenCalledTimes(1); - expect(runtimeLog).toHaveBeenCalledTimes(1); - expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown"); - }); - - it("degrades reply threading when refresh succeeds with private_api: false", async () => { - privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(false); - fetchServerInfoMock.mockResolvedValueOnce({ private_api: false }); - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-disabled" } })), - }); - - const runtimeLog = vi.fn(); - setBlueBubblesRuntime({ - ...runtimeStub, - log: runtimeLog, - } as unknown as PluginRuntime); - - const result = await sendBlueBubblesAttachment({ - to: "chat_guid:iMessage;-;+15551234567", - buffer: new Uint8Array([1, 2, 3]), - filename: "photo.jpg", - contentType: "image/jpeg", - replyToMessageGuid: "reply-guid-disabled", - opts: { serverUrl: "http://localhost:1234", password: "test" }, - }); - - expect(result.messageId).toBe("msg-disabled"); - expect(fetchServerInfoMock).toHaveBeenCalledTimes(1); - // No warning — status is known (disabled), not unknown - expect(runtimeLog).not.toHaveBeenCalled(); - const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; - const bodyText = decodeBody(body); - expect(bodyText).not.toContain('name="selectedMessageGuid"'); - expect(bodyText).not.toContain('name="method"'); - }); - - it("does not refresh when no reply threading is requested", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-plain" } })), - }); - - await sendBlueBubblesAttachment({ - to: "chat_guid:iMessage;-;+15551234567", - buffer: new Uint8Array([1, 2, 3]), - filename: "photo.jpg", - contentType: "image/jpeg", - opts: { serverUrl: "http://localhost:1234", password: "test" }, - }); - - expect(fetchServerInfoMock).not.toHaveBeenCalled(); - }); - }); - - it("still throws for non-handle targets when chatGuid is not found", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }); - - await expect( - sendBlueBubblesAttachment({ - to: "chat_id:999", - buffer: new Uint8Array([1, 2, 3]), - filename: "photo.jpg", - opts: { serverUrl: "http://localhost:1234", password: "test" }, - }), - ).rejects.toThrow("chatGuid not found"); - }); -}); - -describe("fetchBlueBubblesMessageAttachments", () => { - beforeEach(() => { - mockFetch.mockReset(); - }); - - it("returns attachments from the BB API response", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: { - attachments: [ - { - guid: "att-1", - mimeType: "image/jpeg", - transferName: "photo.jpg", - totalBytes: 1024, - }, - { - guid: "att-2", - mime_type: "image/png", - transfer_name: "screenshot.png", - total_bytes: 2048, - }, - ], - }, - }), - }); - const result = await fetchBlueBubblesMessageAttachments("msg-guid", { - baseUrl: "http://localhost:1234", - password: "test", - }); - expect(result).toHaveLength(2); - expect(result[0].guid).toBe("att-1"); - expect(result[0].mimeType).toBe("image/jpeg"); - expect(result[1].guid).toBe("att-2"); - expect(result[1].mimeType).toBe("image/png"); - }); - - it("returns empty array on non-ok HTTP response", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404, - }); - const result = await fetchBlueBubblesMessageAttachments("msg-guid", { - baseUrl: "http://localhost:1234", - password: "test", - }); - expect(result).toEqual([]); - }); - - it("returns empty array when data has no attachments", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: {} }), - }); - const result = await fetchBlueBubblesMessageAttachments("msg-guid", { - baseUrl: "http://localhost:1234", - password: "test", - }); - expect(result).toEqual([]); - }); - - it("includes entries without a guid (downstream download handles filtering)", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: { - attachments: [{ mimeType: "image/jpeg" }, { guid: "att-valid", mimeType: "image/png" }], - }, - }), - }); - const result = await fetchBlueBubblesMessageAttachments("msg-guid", { - baseUrl: "http://localhost:1234", - password: "test", - }); - expect(result).toHaveLength(2); - expect(result[0].guid).toBeUndefined(); - expect(result[1].guid).toBe("att-valid"); - }); -}); diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts deleted file mode 100644 index f8678a0bb86f..000000000000 --- a/extensions/bluebubbles/src/attachments.ts +++ /dev/null @@ -1,302 +0,0 @@ -import crypto from "node:crypto"; -import path from "node:path"; -import { sanitizeUntrustedFileName } from "openclaw/plugin-sdk/security-runtime"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalLowercaseString, - normalizeOptionalString, -} from "openclaw/plugin-sdk/text-runtime"; -import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; -import { - createBlueBubblesClient, - createBlueBubblesClientFromParts, - type BlueBubblesClient, -} from "./client.js"; -import { assertMultipartActionOk } from "./multipart.js"; -import { - fetchBlueBubblesServerInfo, - getCachedBlueBubblesPrivateApiStatus, - isBlueBubblesPrivateApiStatusEnabled, -} from "./probe.js"; -import type { OpenClawConfig } from "./runtime-api.js"; -import { warnBlueBubbles } from "./runtime.js"; -import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js"; -import { createChatForHandle, resolveChatGuidForTarget } from "./send.js"; -import { type BlueBubblesAttachment } from "./types.js"; - -type BlueBubblesAttachmentOpts = { - serverUrl?: string; - password?: string; - accountId?: string; - timeoutMs?: number; - cfg?: OpenClawConfig; -}; - -const AUDIO_MIME_MP3 = new Set(["audio/mpeg", "audio/mp3"]); -const AUDIO_MIME_CAF = new Set(["audio/x-caf", "audio/caf"]); - -function sanitizeFilename(input: string | undefined, fallback: string): string { - const name = sanitizeUntrustedFileName(input ?? "", fallback); - // Strip characters that could enable multipart header injection (CWE-93) - return name.replace(/[\r\n"\\]/g, "_"); -} - -function ensureExtension(filename: string, extension: string, fallbackBase: string): string { - const currentExt = path.extname(filename); - if (normalizeLowercaseStringOrEmpty(currentExt) === extension) { - return filename; - } - const base = currentExt ? filename.slice(0, -currentExt.length) : filename; - return `${base || fallbackBase}${extension}`; -} - -function resolveVoiceInfo(filename: string, contentType?: string) { - const normalizedType = normalizeOptionalLowercaseString(contentType); - const extension = normalizeLowercaseStringOrEmpty(path.extname(filename)); - const isMp3 = - extension === ".mp3" || (normalizedType ? AUDIO_MIME_MP3.has(normalizedType) : false); - const isCaf = - extension === ".caf" || (normalizedType ? AUDIO_MIME_CAF.has(normalizedType) : false); - const isAudio = isMp3 || isCaf || Boolean(normalizedType?.startsWith("audio/")); - return { isAudio, isMp3, isCaf }; -} - -function clientFromOpts(params: BlueBubblesAttachmentOpts): BlueBubblesClient { - return createBlueBubblesClient(params); -} - -function resolveAccount(params: BlueBubblesAttachmentOpts) { - return resolveBlueBubblesServerAccount(params); -} - -/** - * Fetch attachment metadata for a message from the BlueBubbles API. - * - * BlueBubbles sometimes fires the `new-message` webhook before attachment - * indexing is complete, so `attachments` arrives as `[]`. This function - * GETs the message by GUID and returns whatever attachments the server - * has indexed by now. (#65430, #67437) - */ -export async function fetchBlueBubblesMessageAttachments( - messageGuid: string, - opts: { - baseUrl: string; - password: string; - timeoutMs?: number; - allowPrivateNetwork?: boolean; - }, -): Promise { - const client = createBlueBubblesClientFromParts({ - baseUrl: opts.baseUrl, - password: opts.password, - allowPrivateNetwork: opts.allowPrivateNetwork === true, - timeoutMs: opts.timeoutMs, - }); - return await client.getMessageAttachments({ messageGuid, timeoutMs: opts.timeoutMs }); -} - -export async function downloadBlueBubblesAttachment( - attachment: BlueBubblesAttachment, - opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {}, -): Promise<{ buffer: Uint8Array; contentType?: string }> { - const client = clientFromOpts(opts); - // client.downloadAttachment threads this.ssrfPolicy to BOTH fetchRemoteMedia - // and the fetchImpl callback — closing the gap in #34749 where the legacy - // helper silently omitted the policy on the callback path. - return await client.downloadAttachment({ - attachment, - maxBytes: opts.maxBytes, - timeoutMs: opts.timeoutMs, - }); -} - -type SendBlueBubblesAttachmentResult = { - messageId: string; -}; - -/** - * Send an attachment via BlueBubbles API. - * Supports sending media files (images, videos, audio, documents) to a chat. - * When asVoice is true, expects MP3/CAF audio and marks it as an iMessage voice memo. - */ -export async function sendBlueBubblesAttachment(params: { - to: string; - buffer: Uint8Array; - filename: string; - contentType?: string; - caption?: string; - replyToMessageGuid?: string; - replyToPartIndex?: number; - asVoice?: boolean; - opts?: BlueBubblesAttachmentOpts; -}): Promise { - const { to, caption, replyToMessageGuid, replyToPartIndex, asVoice, opts = {} } = params; - let { buffer, filename, contentType } = params; - const wantsVoice = asVoice === true; - const fallbackName = wantsVoice ? "Audio Message" : "attachment"; - filename = sanitizeFilename(filename, fallbackName); - contentType = normalizeOptionalString(contentType); - // Resolve account tuple for helpers that still need baseUrl/password - // (createChatForHandle, resolveChatGuidForTarget, fetchBlueBubblesServerInfo). - // These migrate to the client in subsequent passes. For this callsite, the - // client owns the actual attachment POST; the resolved tuple stays alongside - // so chat-guid resolution and Private API probe continue to work. - const { baseUrl, password, accountId, allowPrivateNetwork } = resolveAccount(opts); - const client = createBlueBubblesClient(opts); - let privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId); - - // Lazy refresh: when the cache has expired and Private API features are needed, - // fetch server info before making the decision. This prevents silent degradation - // of reply threading after the 10-minute cache TTL expires. (#43764) - const wantsReplyThread = Boolean(replyToMessageGuid?.trim()); - if (privateApiStatus === null && wantsReplyThread) { - try { - await fetchBlueBubblesServerInfo({ - baseUrl, - password, - accountId, - timeoutMs: opts.timeoutMs ?? 5000, - allowPrivateNetwork, - }); - privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId); - } catch { - // Refresh failed — proceed with null status (existing graceful degradation) - } - } - - const privateApiEnabled = isBlueBubblesPrivateApiStatusEnabled(privateApiStatus); - - // Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage). - const isAudioMessage = wantsVoice; - if (isAudioMessage) { - const voiceInfo = resolveVoiceInfo(filename, contentType); - if (!voiceInfo.isAudio) { - throw new Error("BlueBubbles voice messages require audio media (mp3 or caf)."); - } - if (voiceInfo.isMp3) { - filename = ensureExtension(filename, ".mp3", fallbackName); - contentType = contentType ?? "audio/mpeg"; - } else if (voiceInfo.isCaf) { - filename = ensureExtension(filename, ".caf", fallbackName); - contentType = contentType ?? "audio/x-caf"; - } else { - throw new Error( - "BlueBubbles voice messages require mp3 or caf audio (convert before sending).", - ); - } - } - - const target = resolveBlueBubblesSendTarget(to); - let chatGuid = await resolveChatGuidForTarget({ - baseUrl, - password, - timeoutMs: opts.timeoutMs, - target, - allowPrivateNetwork, - }); - if (!chatGuid) { - // For handle targets (phone numbers/emails), auto-create a new DM chat - if (target.kind === "handle") { - const created = await createChatForHandle({ - baseUrl, - password, - address: target.address, - timeoutMs: opts.timeoutMs, - allowPrivateNetwork, - }); - chatGuid = created.chatGuid; - // If we still don't have a chatGuid, try resolving again (chat was created server-side) - if (!chatGuid) { - chatGuid = await resolveChatGuidForTarget({ - baseUrl, - password, - timeoutMs: opts.timeoutMs, - target, - allowPrivateNetwork, - }); - } - } - if (!chatGuid) { - throw new Error( - "BlueBubbles attachment send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.", - ); - } - } - - // Build FormData with the attachment - const boundary = `----BlueBubblesFormBoundary${crypto.randomUUID().replace(/-/g, "")}`; - const parts: Uint8Array[] = []; - const encoder = new TextEncoder(); - - // Helper to add a form field - const addField = (name: string, value: string) => { - parts.push(encoder.encode(`--${boundary}\r\n`)); - parts.push(encoder.encode(`Content-Disposition: form-data; name="${name}"\r\n\r\n`)); - parts.push(encoder.encode(`${value}\r\n`)); - }; - - // Helper to add a file field - const addFile = (name: string, fileBuffer: Uint8Array, fileName: string, mimeType?: string) => { - parts.push(encoder.encode(`--${boundary}\r\n`)); - parts.push( - encoder.encode(`Content-Disposition: form-data; name="${name}"; filename="${fileName}"\r\n`), - ); - parts.push(encoder.encode(`Content-Type: ${mimeType ?? "application/octet-stream"}\r\n\r\n`)); - parts.push(fileBuffer); - parts.push(encoder.encode("\r\n")); - }; - - // Add required fields - addFile("attachment", buffer, filename, contentType); - addField("chatGuid", chatGuid); - addField("name", filename); - addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`); - if (privateApiEnabled) { - addField("method", "private-api"); - } - - // Add isAudioMessage flag for voice memos - if (isAudioMessage) { - addField("isAudioMessage", "true"); - } - - const trimmedReplyTo = replyToMessageGuid?.trim(); - if (trimmedReplyTo && privateApiEnabled) { - addField("selectedMessageGuid", trimmedReplyTo); - addField("partIndex", typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0"); - } else if (trimmedReplyTo && privateApiStatus === null) { - warnBlueBubbles( - "Private API status unknown; sending attachment without reply threading metadata. Run a status probe to restore private-api reply features.", - ); - } - - // Add optional caption - if (caption) { - addField("message", caption); - addField("text", caption); - addField("caption", caption); - } - - // Close the multipart body - parts.push(encoder.encode(`--${boundary}--\r\n`)); - - const res = await client.requestMultipart({ - path: "/api/v1/message/attachment", - boundary, - parts, - timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads - }); - - await assertMultipartActionOk(res, "attachment send"); - - const responseBody = await res.text(); - if (!responseBody) { - return { messageId: "ok" }; - } - try { - const parsed = JSON.parse(responseBody) as unknown; - return { messageId: extractBlueBubblesMessageId(parsed) }; - } catch { - return { messageId: "ok" }; - } -} diff --git a/extensions/bluebubbles/src/catchup.test.ts b/extensions/bluebubbles/src/catchup.test.ts deleted file mode 100644 index 0309806587aa..000000000000 --- a/extensions/bluebubbles/src/catchup.test.ts +++ /dev/null @@ -1,1125 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - fetchBlueBubblesMessagesSince, - loadBlueBubblesCatchupCursor, - runBlueBubblesCatchup, - saveBlueBubblesCatchupCursor, -} from "./catchup.js"; -import type { NormalizedWebhookMessage } from "./monitor-normalize.js"; -import type { WebhookTarget } from "./monitor-shared.js"; - -const originalStateDir = process.env.OPENCLAW_STATE_DIR; - -function makeStateDir(): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catchup-test-")); - process.env.OPENCLAW_STATE_DIR = dir; - return dir; -} - -function clearStateDir(dir: string): void { - if (originalStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = originalStateDir; - } - fs.rmSync(dir, { recursive: true, force: true }); -} - -function makeTarget(overrides: Partial = {}): WebhookTarget { - const accountId = overrides.accountId ?? "test-account"; - return { - account: { - accountId, - enabled: true, - name: accountId, - configured: true, - baseUrl: "http://127.0.0.1:1234", - config: { - serverUrl: "http://127.0.0.1:1234", - password: "test-password", - network: { dangerouslyAllowPrivateNetwork: true }, - } as unknown as WebhookTarget["account"]["config"], - }, - config: {} as unknown as WebhookTarget["config"], - runtime: { log: () => {}, error: () => {} }, - core: {} as unknown as WebhookTarget["core"], - path: "/bluebubbles-webhook", - ...overrides, - }; -} - -function makeBbMessage(over: Partial> = {}): Record { - return { - guid: `guid-${Math.random().toString(36).slice(2, 10)}`, - text: "hello", - dateCreated: 2_000, - handle: { address: "+15555550123" }, - chats: [{ guid: "iMessage;-;+15555550123" }], - isFromMe: false, - ...over, - }; -} - -describe("catchup cursor persistence", () => { - let stateDir: string; - beforeEach(() => { - stateDir = makeStateDir(); - }); - afterEach(() => { - clearStateDir(stateDir); - }); - - it("returns null before the first save", async () => { - expect(await loadBlueBubblesCatchupCursor("acct")).toBeNull(); - }); - - it("round-trips a saved cursor", async () => { - await saveBlueBubblesCatchupCursor("acct", 1_234_567); - const loaded = await loadBlueBubblesCatchupCursor("acct"); - expect(loaded?.lastSeenMs).toBe(1_234_567); - expect(typeof loaded?.updatedAt).toBe("number"); - }); - - it("scopes cursor files per account", async () => { - await saveBlueBubblesCatchupCursor("a", 100); - await saveBlueBubblesCatchupCursor("b", 200); - expect((await loadBlueBubblesCatchupCursor("a"))?.lastSeenMs).toBe(100); - expect((await loadBlueBubblesCatchupCursor("b"))?.lastSeenMs).toBe(200); - }); - - it("treats filesystem-unsafe account IDs as distinct", async () => { - // Different account IDs that happen to map to the same safePrefix must - // not collide on disk. - await saveBlueBubblesCatchupCursor("acct/a", 111); - await saveBlueBubblesCatchupCursor("acct:a", 222); - expect((await loadBlueBubblesCatchupCursor("acct/a"))?.lastSeenMs).toBe(111); - expect((await loadBlueBubblesCatchupCursor("acct:a"))?.lastSeenMs).toBe(222); - }); -}); - -describe("runBlueBubblesCatchup", () => { - let stateDir: string; - beforeEach(() => { - stateDir = makeStateDir(); - }); - afterEach(() => { - clearStateDir(stateDir); - vi.restoreAllMocks(); - }); - - it("coalesces concurrent runs for the same accountId via in-process singleflight", async () => { - // Two calls firing simultaneously must share one run, one fetch, one - // set of processMessage calls, one cursor write. Without singleflight, - // both calls would read the same cursor, both would process the same - // messages twice (caught by #66816 dedupe, but wasteful), and the - // second writer could regress the cursor if its nowMs is stale. - const now = 10 * 60 * 1000; - await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000); - - let fetchCount = 0; - let processCount = 0; - let releaseFetch: (() => void) | undefined; - let fetchStartedResolve: (() => void) | undefined; - const fetchStarted = new Promise((resolve) => { - fetchStartedResolve = resolve; - }); - - const call1 = runBlueBubblesCatchup(makeTarget(), { - now: () => now, - fetchMessages: async () => { - fetchCount++; - fetchStartedResolve?.(); - // Block until we fire the second call, so we can verify it - // coalesces rather than starting a new run. - await new Promise((resolve) => { - releaseFetch = resolve; - }); - return { - resolved: true, - messages: [makeBbMessage({ guid: "g1", dateCreated: 6 * 60 * 1000 })], - }; - }, - processMessageFn: async () => { - processCount++; - }, - }); - - // Wait for call1 to enter fetchMessages, then fire call2. A fixed - // sleep is load-sensitive and can leave call1 permanently blocked. - await fetchStarted; - const call2 = runBlueBubblesCatchup(makeTarget(), { - now: () => now, - fetchMessages: async () => { - fetchCount++; - return { resolved: true, messages: [makeBbMessage({ guid: "g2" })] }; - }, - processMessageFn: async () => { - processCount++; - }, - }); - - releaseFetch?.(); - const [r1, r2] = await Promise.all([call1, call2]); - - expect(fetchCount).toBe(1); // second call coalesced, didn't re-fetch - expect(processCount).toBe(1); - expect(r1).toBe(r2); // same summary object returned to both callers - }); - - it("replays messages and advances the cursor on success", async () => { - const now = 10_000; - const processed: NormalizedWebhookMessage[] = []; - const summary = await runBlueBubblesCatchup(makeTarget(), { - now: () => now, - fetchMessages: async () => ({ - resolved: true, - messages: [ - makeBbMessage({ guid: "g1", text: "one", dateCreated: 9_000 }), - makeBbMessage({ guid: "g2", text: "two", dateCreated: 9_500 }), - ], - }), - processMessageFn: async (message) => { - processed.push(message); - }, - }); - - expect(summary?.querySucceeded).toBe(true); - expect(summary?.replayed).toBe(2); - expect(summary?.failed).toBe(0); - expect(processed.map((m) => m.messageId)).toEqual(["g1", "g2"]); - const cursor = await loadBlueBubblesCatchupCursor("test-account"); - expect(cursor?.lastSeenMs).toBe(now); - }); - - it("clamps first-run lookback to maxAgeMinutes when smaller", async () => { - const now = 1_000_000; - let seenSince = -1; - await runBlueBubblesCatchup( - makeTarget({ - account: { - accountId: "test-account", - enabled: true, - configured: true, - baseUrl: "http://127.0.0.1:1234", - config: { - serverUrl: "http://127.0.0.1:1234", - password: "x", - network: { dangerouslyAllowPrivateNetwork: true }, - // maxAge tighter than firstRunLookback — must clamp on first run. - catchup: { maxAgeMinutes: 5, firstRunLookbackMinutes: 30 }, - } as unknown as WebhookTarget["account"]["config"], - }, - }), - { - now: () => now, - fetchMessages: async (sinceMs) => { - seenSince = sinceMs; - return { resolved: true, messages: [] }; - }, - processMessageFn: async () => {}, - }, - ); - expect(seenSince).toBe(now - 5 * 60_000); - }); - - it("uses firstRunLookback when no cursor exists", async () => { - const now = 1_000_000; - let seenSince = 0; - await runBlueBubblesCatchup( - makeTarget({ - account: { - accountId: "test-account", - enabled: true, - configured: true, - baseUrl: "http://127.0.0.1:1234", - config: { - serverUrl: "http://127.0.0.1:1234", - password: "x", - network: { dangerouslyAllowPrivateNetwork: true }, - catchup: { firstRunLookbackMinutes: 5 }, - } as unknown as WebhookTarget["account"]["config"], - }, - }), - { - now: () => now, - fetchMessages: async (sinceMs) => { - seenSince = sinceMs; - return { resolved: true, messages: [] }; - }, - processMessageFn: async () => {}, - }, - ); - expect(seenSince).toBe(now - 5 * 60_000); - }); - - it("clamps window to maxAgeMinutes when cursor is older", async () => { - const now = 100 * 60_000; - await saveBlueBubblesCatchupCursor("test-account", 0); - let seenSince = -1; - await runBlueBubblesCatchup( - makeTarget({ - account: { - accountId: "test-account", - enabled: true, - configured: true, - baseUrl: "http://127.0.0.1:1234", - config: { - serverUrl: "http://127.0.0.1:1234", - password: "x", - network: { dangerouslyAllowPrivateNetwork: true }, - catchup: { maxAgeMinutes: 10 }, - } as unknown as WebhookTarget["account"]["config"], - }, - }), - { - now: () => now, - fetchMessages: async (sinceMs) => { - seenSince = sinceMs; - return { resolved: true, messages: [] }; - }, - processMessageFn: async () => {}, - }, - ); - expect(seenSince).toBe(now - 10 * 60_000); - }); - - it("skips when enabled: false", async () => { - const called = { fetch: 0, proc: 0 }; - const summary = await runBlueBubblesCatchup( - makeTarget({ - account: { - accountId: "test-account", - enabled: true, - configured: true, - baseUrl: "http://127.0.0.1:1234", - config: { - serverUrl: "http://127.0.0.1:1234", - password: "x", - network: { dangerouslyAllowPrivateNetwork: true }, - catchup: { enabled: false }, - } as unknown as WebhookTarget["account"]["config"], - }, - }), - { - now: () => 1_000, - fetchMessages: async () => { - called.fetch++; - return { resolved: true, messages: [] }; - }, - processMessageFn: async () => { - called.proc++; - }, - }, - ); - expect(summary).toBeNull(); - expect(called.fetch).toBe(0); - expect(called.proc).toBe(0); - }); - - it("runs catchup even on rapid restarts (no min-interval gate)", async () => { - // Catchup runs once per gateway startup, so a quick restart MUST run - // it again — otherwise messages dropped between the two startups - // (gateway down → BB ECONNREFUSED → gateway up <30s later) are lost - // permanently. Bounded by perRunLimit/maxAge + dedupe-protected. - const now = 10_000; - await saveBlueBubblesCatchupCursor("test-account", now - 5_000); - let fetched = false; - const summary = await runBlueBubblesCatchup(makeTarget(), { - now: () => now, - fetchMessages: async () => { - fetched = true; - return { resolved: true, messages: [] }; - }, - processMessageFn: async () => {}, - }); - expect(fetched).toBe(true); - expect(summary).not.toBeNull(); - }); - - it("advances cursor only to last fetched ts when result is truncated (perRunLimit hit)", async () => { - // Long-outage scenario: 4 messages arrived during downtime but - // perRunLimit=2. Sort:ASC means we get the 2 oldest. Cursor must - // advance to the 2nd's timestamp (not nowMs) so the next startup - // picks up the remaining 2. - const now = 100 * 60 * 1000; - await saveBlueBubblesCatchupCursor("test-account", 50 * 60 * 1000); - const summary = await runBlueBubblesCatchup( - makeTarget({ - account: { - accountId: "test-account", - enabled: true, - configured: true, - baseUrl: "http://127.0.0.1:1234", - config: { - serverUrl: "http://127.0.0.1:1234", - password: "x", - network: { dangerouslyAllowPrivateNetwork: true }, - catchup: { perRunLimit: 2 }, - } as unknown as WebhookTarget["account"]["config"], - }, - }), - { - now: () => now, - fetchMessages: async () => ({ - resolved: true, - // Only the 2 the cap allows BB to return (oldest first via ASC). - messages: [ - makeBbMessage({ guid: "p1", dateCreated: 60 * 60 * 1000 }), - makeBbMessage({ guid: "p2", dateCreated: 70 * 60 * 1000 }), - ], - }), - processMessageFn: async () => {}, - }, - ); - expect(summary?.replayed).toBe(2); - expect(summary?.fetchedCount).toBe(2); - expect(summary?.cursorAfter).toBe(70 * 60 * 1000); // page boundary, not nowMs - const cursor = await loadBlueBubblesCatchupCursor("test-account"); - expect(cursor?.lastSeenMs).toBe(70 * 60 * 1000); - }); - - it("filters isFromMe before dispatch and still advances cursor", async () => { - const now = 10_000; - const processed: NormalizedWebhookMessage[] = []; - const summary = await runBlueBubblesCatchup(makeTarget(), { - now: () => now, - fetchMessages: async () => ({ - resolved: true, - messages: [ - makeBbMessage({ guid: "g-me", text: "self", dateCreated: 9_500, isFromMe: true }), - makeBbMessage({ guid: "g-them", text: "them", dateCreated: 9_500 }), - ], - }), - processMessageFn: async (m) => { - processed.push(m); - }, - }); - expect(summary?.replayed).toBe(1); - expect(summary?.skippedFromMe).toBe(1); - expect(processed.map((m) => m.messageId)).toEqual(["g-them"]); - }); - - it("leaves cursor unchanged when the query fails", async () => { - // Use timestamps well past MIN_INTERVAL_MS (30s) so the rate-limit skip - // doesn't short-circuit the run before the fetch path fires. - const now = 10 * 60 * 1000; - await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000); - const summary = await runBlueBubblesCatchup(makeTarget(), { - now: () => now, - fetchMessages: async () => ({ resolved: false, messages: [] }), - processMessageFn: async () => {}, - }); - expect(summary?.querySucceeded).toBe(false); - const cursor = await loadBlueBubblesCatchupCursor("test-account"); - expect(cursor?.lastSeenMs).toBe(5 * 60 * 1000); // unchanged - }); - - it("does NOT advance cursor past a processMessage failure (retryable)", async () => { - const cursorBefore = 5 * 60 * 1000; - const now = 10 * 60 * 1000; - await saveBlueBubblesCatchupCursor("test-account", cursorBefore); - const summary = await runBlueBubblesCatchup(makeTarget(), { - now: () => now, - fetchMessages: async () => ({ - resolved: true, - messages: [ - makeBbMessage({ guid: "ok1", dateCreated: 6 * 60 * 1000 }), - makeBbMessage({ guid: "bad", dateCreated: 7 * 60 * 1000 }), - makeBbMessage({ guid: "ok2", dateCreated: 8 * 60 * 1000 }), - ], - }), - processMessageFn: async (m) => { - if (m.messageId === "bad") { - throw new Error("transient"); - } - }, - }); - // Cursor is held just before the bad message's timestamp so the next - // sweep retries it (and re-queries ok1 which dedupe will drop). - expect(summary?.failed).toBe(1); - expect(summary?.givenUp).toBe(0); - expect(summary?.cursorAfter).toBe(7 * 60 * 1000 - 1); - const cursorAfter = await loadBlueBubblesCatchupCursor("test-account"); - expect(cursorAfter?.lastSeenMs).toBe(7 * 60 * 1000 - 1); - // Retry counter is persisted so subsequent sweeps know how close we - // are to the give-up ceiling. - expect(cursorAfter?.failureRetries?.bad).toBe(1); - }); - - it("clamps held cursor to previous cursor when failure ts is below it", async () => { - // Pathological: failure timestamp is at or below the previous cursor - // (shouldn't happen with server-side `after:` but defense in depth). - // We must never regress the cursor. - const cursorBefore = 9 * 60 * 1000; - const now = 10 * 60 * 1000; - await saveBlueBubblesCatchupCursor("test-account", cursorBefore); - const summary = await runBlueBubblesCatchup(makeTarget(), { - now: () => now, - fetchMessages: async () => ({ - resolved: true, - messages: [makeBbMessage({ guid: "bad", dateCreated: 1_000 })], - }), - processMessageFn: async () => { - throw new Error("transient"); - }, - }); - // skippedPreCursor catches the bad record before processMessage runs, - // so no failure is recorded and cursor advances to nowMs normally. - expect(summary?.failed).toBe(0); - expect(summary?.skippedPreCursor).toBe(1); - expect(summary?.cursorAfter).toBe(now); - }); - - it("recovers from a future-dated cursor by falling through to firstRunLookback", async () => { - // Clock-skew scenario: cursor was written with a wall time that is now - // ahead of the corrected clock. Catchup must NOT pass `after=future` - // to BB (which would return zero), and must NOT save cursor=nowMs - // without first replaying the [earliestAllowed, nowMs] window. - const now = 1_000_000; - const futureCursor = now + 60_000; - await saveBlueBubblesCatchupCursor("test-account", futureCursor); - let seenSince = -1; - const summary = await runBlueBubblesCatchup(makeTarget(), { - now: () => now, - fetchMessages: async (sinceMs) => { - seenSince = sinceMs; - return { resolved: true, messages: [] }; - }, - processMessageFn: async () => {}, - }); - // Should fall through to firstRunLookback (default 30 min), clamped - // to maxAge (default 120 min) — i.e. nowMs - 30min, NOT nowMs. - expect(seenSince).toBe(now - 30 * 60_000); - expect(summary).not.toBeNull(); - // Cursor should be repaired to nowMs so subsequent runs are normal. - const repaired = await loadBlueBubblesCatchupCursor("test-account"); - expect(repaired?.lastSeenMs).toBe(now); - }); - - it("isolates one failing message and keeps processing the rest", async () => { - const now = 10_000; - const processed: string[] = []; - const summary = await runBlueBubblesCatchup(makeTarget(), { - now: () => now, - fetchMessages: async () => ({ - resolved: true, - messages: [ - makeBbMessage({ guid: "ok1", text: "ok1" }), - makeBbMessage({ guid: "bad", text: "bad" }), - makeBbMessage({ guid: "ok2", text: "ok2" }), - ], - }), - processMessageFn: async (m) => { - if (m.messageId === "bad") { - throw new Error("boom"); - } - processed.push(m.messageId ?? "?"); - }, - }); - expect(summary?.replayed).toBe(2); - expect(summary?.failed).toBe(1); - expect(processed).toEqual(["ok1", "ok2"]); - }); - - it("warns when fetched count hits perRunLimit so silent truncation is visible", async () => { - const now = 10 * 60 * 1000; - await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000); - const warnings: string[] = []; - const summary = await runBlueBubblesCatchup( - makeTarget({ - account: { - accountId: "test-account", - enabled: true, - configured: true, - baseUrl: "http://127.0.0.1:1234", - config: { - serverUrl: "http://127.0.0.1:1234", - password: "x", - network: { dangerouslyAllowPrivateNetwork: true }, - catchup: { perRunLimit: 3 }, - } as unknown as WebhookTarget["account"]["config"], - }, - }), - { - now: () => now, - fetchMessages: async () => ({ - resolved: true, - messages: [ - makeBbMessage({ guid: "a", dateCreated: 6 * 60 * 1000 }), - makeBbMessage({ guid: "b", dateCreated: 7 * 60 * 1000 }), - makeBbMessage({ guid: "c", dateCreated: 8 * 60 * 1000 }), - ], - }), - processMessageFn: async () => {}, - error: (msg) => warnings.push(msg), - }, - ); - expect(summary?.replayed).toBe(3); - expect(summary?.fetchedCount).toBe(3); - const truncationWarnings = warnings.filter((w) => w.includes("perRunLimit")); - expect(truncationWarnings).toHaveLength(1); - expect(truncationWarnings[0]).toContain("WARNING"); - expect(truncationWarnings[0]).toContain("perRunLimit=3"); - }); - - it("does not warn when fetched count is below perRunLimit", async () => { - const now = 10 * 60 * 1000; - await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000); - const warnings: string[] = []; - await runBlueBubblesCatchup( - makeTarget({ - account: { - accountId: "test-account", - enabled: true, - configured: true, - baseUrl: "http://127.0.0.1:1234", - config: { - serverUrl: "http://127.0.0.1:1234", - password: "x", - network: { dangerouslyAllowPrivateNetwork: true }, - catchup: { perRunLimit: 50 }, - } as unknown as WebhookTarget["account"]["config"], - }, - }), - { - now: () => now, - fetchMessages: async () => ({ - resolved: true, - messages: [makeBbMessage({ guid: "a" }), makeBbMessage({ guid: "b" })], - }), - processMessageFn: async () => {}, - error: (msg) => warnings.push(msg), - }, - ); - expect(warnings.filter((w) => w.includes("perRunLimit"))).toHaveLength(0); - }); - - it("skips pre-cursor timestamps as defense in depth against server-inclusive bounds", async () => { - const cursor = 5 * 60 * 1000; - const now = 10 * 60 * 1000; - await saveBlueBubblesCatchupCursor("test-account", cursor); - const processed: string[] = []; - const summary = await runBlueBubblesCatchup(makeTarget(), { - now: () => now, - fetchMessages: async () => ({ - resolved: true, - messages: [ - makeBbMessage({ guid: "before", text: "before", dateCreated: cursor - 1_000 }), - makeBbMessage({ guid: "at-boundary", text: "boundary", dateCreated: cursor }), - makeBbMessage({ guid: "after", text: "after", dateCreated: cursor + 1_000 }), - ], - }), - processMessageFn: async (m) => { - processed.push(m.messageId ?? "?"); - }, - }); - expect(summary?.replayed).toBe(1); - expect(summary?.skippedPreCursor).toBe(2); - expect(processed).toEqual(["after"]); - }); -}); - -describe("runBlueBubblesCatchup — per-message retry cap", () => { - let stateDir: string; - beforeEach(() => { - stateDir = makeStateDir(); - }); - afterEach(() => { - clearStateDir(stateDir); - vi.restoreAllMocks(); - }); - - it("increments retry counter on each consecutive failure and holds cursor", async () => { - // Three sweeps, all fail on the same GUID. Counter accumulates and - // cursor stays pinned below the failing message so every sweep - // retries it. maxFailureRetries: 5 so we don't give up inside this - // test. - const now1 = 10 * 60 * 1000; - const now2 = now1 + 60 * 1000; - const now3 = now2 + 60 * 1000; - await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000); - - const target = makeTarget({ - account: { - accountId: "test-account", - enabled: true, - configured: true, - baseUrl: "http://127.0.0.1:1234", - config: { - serverUrl: "http://127.0.0.1:1234", - password: "x", - network: { dangerouslyAllowPrivateNetwork: true }, - catchup: { maxFailureRetries: 5 }, - } as unknown as WebhookTarget["account"]["config"], - }, - }); - - const fetchMessages = async () => ({ - resolved: true, - messages: [makeBbMessage({ guid: "wedge", dateCreated: 7 * 60 * 1000 })], - }); - const processMessageFn = async () => { - throw new Error("boom"); - }; - - const s1 = await runBlueBubblesCatchup(target, { - now: () => now1, - fetchMessages, - processMessageFn, - }); - const s2 = await runBlueBubblesCatchup(target, { - now: () => now2, - fetchMessages, - processMessageFn, - }); - const s3 = await runBlueBubblesCatchup(target, { - now: () => now3, - fetchMessages, - processMessageFn, - }); - - expect(s1?.failed).toBe(1); - expect(s1?.givenUp).toBe(0); - expect(s2?.givenUp).toBe(0); - expect(s3?.givenUp).toBe(0); - const cursor = await loadBlueBubblesCatchupCursor("test-account"); - expect(cursor?.failureRetries?.wedge).toBe(3); - // Cursor still held just below the wedge message's timestamp. - expect(cursor?.lastSeenMs).toBe(7 * 60 * 1000 - 1); - }); - - it("gives up on the Nth consecutive failure and records count >= max", async () => { - const now = 10 * 60 * 1000; - await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000); - // Pre-seed a cursor with retries at the one-before-give-up threshold - // so a single run trips the ceiling. This mirrors what would happen - // after many runs through the incremental-retry path above. - await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000, { wedge: 2 }); - - const warnings: string[] = []; - const target = makeTarget({ - account: { - accountId: "test-account", - enabled: true, - configured: true, - baseUrl: "http://127.0.0.1:1234", - config: { - serverUrl: "http://127.0.0.1:1234", - password: "x", - network: { dangerouslyAllowPrivateNetwork: true }, - catchup: { maxFailureRetries: 3 }, - } as unknown as WebhookTarget["account"]["config"], - }, - }); - - const summary = await runBlueBubblesCatchup(target, { - now: () => now, - fetchMessages: async () => ({ - resolved: true, - messages: [makeBbMessage({ guid: "wedge", dateCreated: 7 * 60 * 1000 })], - }), - processMessageFn: async () => { - throw new Error("malformed"); - }, - error: (m) => warnings.push(m), - }); - - expect(summary?.failed).toBe(1); - expect(summary?.givenUp).toBe(1); - // Give-up no longer holds the cursor: it advances to nowMs so the - // wedge message falls out of the next query window entirely. - expect(summary?.cursorAfter).toBe(now); - - const persisted = await loadBlueBubblesCatchupCursor("test-account"); - expect(persisted?.lastSeenMs).toBe(now); - // Counter is persisted at the give-up value so a later sweep that - // still sees the message (e.g., because a different GUID is holding - // the cursor) will recognize the GUID as given up and skip it. - expect(persisted?.failureRetries?.wedge).toBe(3); - - // Distinct WARN log line fired on the give-up transition. - const giveUpWarnings = warnings.filter((w) => w.includes("giving up on guid=")); - expect(giveUpWarnings).toHaveLength(1); - expect(giveUpWarnings[0]).toContain("guid=wedge"); - expect(giveUpWarnings[0]).toContain("3 consecutive failures"); - }); - - it("skips an already-given-up GUID without re-attempting processMessage", async () => { - // Setup: the cursor file was written with wedge already at the - // give-up threshold from a prior run. On this run, the cursor is - // held by a different, still-retrying GUID (`held`), so wedge's - // timestamp falls back into the query window. Catchup must skip - // wedge without invoking processMessage on it. - const now = 10 * 60 * 1000; - await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000, { wedge: 3 }); - - const attempted: string[] = []; - const target = makeTarget({ - account: { - accountId: "test-account", - enabled: true, - configured: true, - baseUrl: "http://127.0.0.1:1234", - config: { - serverUrl: "http://127.0.0.1:1234", - password: "x", - network: { dangerouslyAllowPrivateNetwork: true }, - catchup: { maxFailureRetries: 3 }, - } as unknown as WebhookTarget["account"]["config"], - }, - }); - - const summary = await runBlueBubblesCatchup(target, { - now: () => now, - fetchMessages: async () => ({ - resolved: true, - messages: [ - makeBbMessage({ guid: "held", dateCreated: 6 * 60 * 1000 }), - makeBbMessage({ guid: "wedge", dateCreated: 7 * 60 * 1000 }), - ], - }), - processMessageFn: async (m) => { - attempted.push(m.messageId ?? "?"); - if (m.messageId === "held") { - throw new Error("transient"); - } - }, - }); - - // processMessage never runs for wedge. - expect(attempted).toEqual(["held"]); - expect(summary?.skippedGivenUp).toBe(1); - expect(summary?.failed).toBe(1); - expect(summary?.givenUp).toBe(0); - // Cursor held at `held` so held keeps retrying next sweep. - expect(summary?.cursorAfter).toBe(6 * 60 * 1000 - 1); - - const cursor = await loadBlueBubblesCatchupCursor("test-account"); - // Both entries preserved: held at count 1 (still retrying), - // wedge at count 3 (given up, sticky). - expect(cursor?.failureRetries?.held).toBe(1); - expect(cursor?.failureRetries?.wedge).toBe(3); - }); - - it("clears the retry counter on successful processing", async () => { - // GUID recovered after a transient failure. The counter must drop - // so the next failure starts fresh (not carrying forward stale - // retry history). - const now = 10 * 60 * 1000; - await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000, { flaky: 4 }); - - const summary = await runBlueBubblesCatchup(makeTarget(), { - now: () => now, - fetchMessages: async () => ({ - resolved: true, - messages: [makeBbMessage({ guid: "flaky", dateCreated: 6 * 60 * 1000 })], - }), - processMessageFn: async () => { - /* succeeds */ - }, - }); - - expect(summary?.replayed).toBe(1); - const cursor = await loadBlueBubblesCatchupCursor("test-account"); - expect(cursor?.failureRetries?.flaky).toBeUndefined(); - // When the map is empty, the field itself is omitted from the file. - expect(cursor?.failureRetries).toBeUndefined(); - expect(cursor?.lastSeenMs).toBe(now); - }); - - it("resolves 'earlier retry + later give-up' by holding cursor at earlier and skipping later", async () => { - // This is the key scenario issue #66870 exists to solve. GUID A at - // t=6min is still retrying (count=1). GUID B at t=7min has been - // failing for many runs and crosses the ceiling on this run. The - // wrong answer is "advance cursor past B to t=7min" — that would - // lose A. The right answer is "hold cursor below A, record B as - // given-up, skip B on sight next run". - const now = 10 * 60 * 1000; - await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000, { giveUpHere: 2 }); - - const target = makeTarget({ - account: { - accountId: "test-account", - enabled: true, - configured: true, - baseUrl: "http://127.0.0.1:1234", - config: { - serverUrl: "http://127.0.0.1:1234", - password: "x", - network: { dangerouslyAllowPrivateNetwork: true }, - catchup: { maxFailureRetries: 3 }, - } as unknown as WebhookTarget["account"]["config"], - }, - }); - - const summary = await runBlueBubblesCatchup(target, { - now: () => now, - fetchMessages: async () => ({ - resolved: true, - messages: [ - makeBbMessage({ guid: "retryEarlier", dateCreated: 6 * 60 * 1000 }), - makeBbMessage({ guid: "giveUpHere", dateCreated: 7 * 60 * 1000 }), - ], - }), - processMessageFn: async () => { - throw new Error("failing"); - }, - }); - - expect(summary?.failed).toBe(2); - expect(summary?.givenUp).toBe(1); - // Cursor held at (earlier message ts - 1) so retryEarlier keeps retrying. - expect(summary?.cursorAfter).toBe(6 * 60 * 1000 - 1); - - const cursor = await loadBlueBubblesCatchupCursor("test-account"); - expect(cursor?.failureRetries?.retryEarlier).toBe(1); - // Give-up counter preserved at or above the threshold. - expect(cursor?.failureRetries?.giveUpHere).toBe(3); - }); - - it("uses the default retry cap when maxFailureRetries is omitted from config", async () => { - // Boot-strap: record 9 failures, then a 10th should trigger give-up - // at the default threshold. We pre-seed the counter at 9 so this - // single-run test doesn't need to iterate the whole sequence. - const now = 10 * 60 * 1000; - await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000, { wedge: 9 }); - - const warnings: string[] = []; - const summary = await runBlueBubblesCatchup(makeTarget(), { - now: () => now, - fetchMessages: async () => ({ - resolved: true, - messages: [makeBbMessage({ guid: "wedge", dateCreated: 6 * 60 * 1000 })], - }), - processMessageFn: async () => { - throw new Error("boom"); - }, - error: (m) => warnings.push(m), - }); - expect(summary?.givenUp).toBe(1); - expect(warnings.some((w) => w.includes("giving up on guid=wedge"))).toBe(true); - expect(warnings.some((w) => w.includes("10 consecutive failures"))).toBe(true); - }); - - it("clamps maxFailureRetries to >= 1 when configured to zero or negative", async () => { - // With clamp floor of 1, the first failure already meets count >= 1 - // so catchup gives up immediately on first attempt. - const now = 10 * 60 * 1000; - await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000); - - const summary = await runBlueBubblesCatchup( - makeTarget({ - account: { - accountId: "test-account", - enabled: true, - configured: true, - baseUrl: "http://127.0.0.1:1234", - config: { - serverUrl: "http://127.0.0.1:1234", - password: "x", - network: { dangerouslyAllowPrivateNetwork: true }, - catchup: { maxFailureRetries: 0 }, - } as unknown as WebhookTarget["account"]["config"], - }, - }), - { - now: () => now, - fetchMessages: async () => ({ - resolved: true, - messages: [makeBbMessage({ guid: "wedge", dateCreated: 6 * 60 * 1000 })], - }), - processMessageFn: async () => { - throw new Error("boom"); - }, - }, - ); - expect(summary?.givenUp).toBe(1); - expect(summary?.cursorAfter).toBe(now); - }); - - it("loads cleanly from a legacy cursor file without a failureRetries field", async () => { - // Older cursor files (written before this field existed) must still - // parse. Round-trip: save without the field (legacy path), then - // run catchup and confirm a normal sweep proceeds. - await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000); - const loaded = await loadBlueBubblesCatchupCursor("test-account"); - expect(loaded?.lastSeenMs).toBe(5 * 60 * 1000); - expect(loaded?.failureRetries).toBeUndefined(); - - const summary = await runBlueBubblesCatchup(makeTarget(), { - now: () => 10 * 60 * 1000, - fetchMessages: async () => ({ - resolved: true, - messages: [makeBbMessage({ guid: "ok", dateCreated: 6 * 60 * 1000 })], - }), - processMessageFn: async () => {}, - }); - expect(summary?.replayed).toBe(1); - }); - - it("drops retry entries for GUIDs that are no longer in the query window", async () => { - // A stale entry carried in the cursor file (e.g., from an older - // run whose cursor has since advanced past its timestamp) should - // NOT be carried forward if the GUID does not appear in the - // current fetch. Otherwise the map grows without bound over time. - const now = 10 * 60 * 1000; - await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000, { - staleGuid: 2, - alsoStale: 5, - }); - - const summary = await runBlueBubblesCatchup(makeTarget(), { - now: () => now, - fetchMessages: async () => ({ - resolved: true, - // Fetch returns entirely different GUIDs from the stored map. - messages: [makeBbMessage({ guid: "fresh", dateCreated: 6 * 60 * 1000 })], - }), - processMessageFn: async () => {}, - }); - expect(summary?.replayed).toBe(1); - const cursor = await loadBlueBubblesCatchupCursor("test-account"); - // Both stale entries dropped; no new entries since the fresh message - // succeeded. - expect(cursor?.failureRetries).toBeUndefined(); - }); - - it("preserves stickiness when a given-up GUID reappears and fails again", async () => { - // Setup: cursor advanced, but held by a newer still-retrying GUID - // `held`. The wedge GUID is already given up from a prior run and - // still appears because `held` is holding the cursor below it. - // Catchup must continue to skip wedge on sight across many runs - // without ever calling processMessage on it. - const now = 10 * 60 * 1000; - await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000, { - wedge: 10, - held: 1, - }); - - const attempted: string[] = []; - const target = makeTarget({ - account: { - accountId: "test-account", - enabled: true, - configured: true, - baseUrl: "http://127.0.0.1:1234", - config: { - serverUrl: "http://127.0.0.1:1234", - password: "x", - network: { dangerouslyAllowPrivateNetwork: true }, - catchup: { maxFailureRetries: 5 }, - } as unknown as WebhookTarget["account"]["config"], - }, - }); - const fetchMessages = async () => ({ - resolved: true, - messages: [ - makeBbMessage({ guid: "held", dateCreated: 6 * 60 * 1000 }), - makeBbMessage({ guid: "wedge", dateCreated: 7 * 60 * 1000 }), - ], - }); - const processMessageFn = async () => { - throw new Error("still broken"); - }; - - for (let i = 0; i < 3; i++) { - await runBlueBubblesCatchup(target, { - now: () => now + i, - fetchMessages, - processMessageFn: async (m) => { - attempted.push(m.messageId ?? "?"); - return processMessageFn(); - }, - }); - } - // wedge is NEVER attempted despite reappearing every sweep. - expect(attempted.filter((g) => g === "wedge")).toHaveLength(0); - // held is attempted every sweep. - expect(attempted.filter((g) => g === "held")).toHaveLength(3); - }); - - it("summary.skippedGivenUp counter is zero on a clean run", async () => { - const summary = await runBlueBubblesCatchup(makeTarget(), { - now: () => 10_000, - fetchMessages: async () => ({ resolved: true, messages: [] }), - processMessageFn: async () => {}, - }); - expect(summary?.skippedGivenUp).toBe(0); - expect(summary?.givenUp).toBe(0); - }); -}); - -describe("saveBlueBubblesCatchupCursor + loadBlueBubblesCatchupCursor — retry map", () => { - let stateDir: string; - beforeEach(() => { - stateDir = makeStateDir(); - }); - afterEach(() => { - clearStateDir(stateDir); - }); - - it("round-trips an empty retry map by omitting the field from the persisted shape", async () => { - await saveBlueBubblesCatchupCursor("acct", 100, {}); - const loaded = await loadBlueBubblesCatchupCursor("acct"); - expect(loaded?.lastSeenMs).toBe(100); - expect(loaded?.failureRetries).toBeUndefined(); - }); - - it("round-trips a populated retry map", async () => { - await saveBlueBubblesCatchupCursor("acct", 100, { a: 1, b: 9 }); - const loaded = await loadBlueBubblesCatchupCursor("acct"); - expect(loaded?.failureRetries).toEqual({ a: 1, b: 9 }); - }); - - it("filters malformed retry entries during load (zero, negative, non-numeric)", async () => { - // Use the public save to produce the on-disk file, then overwrite - // its contents with a hand-crafted payload to exercise the loader's - // sanitization independently of what the saver would emit. - await saveBlueBubblesCatchupCursor("acct", 100); - const stateRoot = process.env.OPENCLAW_STATE_DIR; - if (!stateRoot) { - throw new Error("OPENCLAW_STATE_DIR must be set by the test harness"); - } - const dir = path.join(stateRoot, "bluebubbles", "catchup"); - const files = fs.readdirSync(dir); - expect(files).toHaveLength(1); - const firstFile = files[0]; - if (!firstFile) { - throw new Error("expected a cursor file to exist after save"); - } - const badCursor = { - lastSeenMs: 100, - updatedAt: 0, - failureRetries: { - good: 3, - zero: 0, - negative: -1, - notANumber: "oops", - infinite: Number.POSITIVE_INFINITY, - nan: Number.NaN, - }, - }; - fs.writeFileSync(path.join(dir, firstFile), JSON.stringify(badCursor)); - - const loaded = await loadBlueBubblesCatchupCursor("acct"); - expect(loaded?.lastSeenMs).toBe(100); - expect(loaded?.failureRetries).toEqual({ good: 3 }); - }); -}); - -describe("fetchBlueBubblesMessagesSince", () => { - it("returns resolved:false when the network call throws", async () => { - // Point at a port nothing is listening on so fetch fails fast. - const result = await fetchBlueBubblesMessagesSince(0, 10, { - baseUrl: "http://127.0.0.1:1", - password: "x", - allowPrivateNetwork: true, - timeoutMs: 200, - }); - expect(result.resolved).toBe(false); - expect(result.messages).toEqual([]); - }); -}); diff --git a/extensions/bluebubbles/src/catchup.ts b/extensions/bluebubbles/src/catchup.ts deleted file mode 100644 index ec49e45a691f..000000000000 --- a/extensions/bluebubbles/src/catchup.ts +++ /dev/null @@ -1,652 +0,0 @@ -import { createHash } from "node:crypto"; -import path from "node:path"; -import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store"; -import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; -import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; -import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; -import { createBlueBubblesClientFromParts } from "./client.js"; -import { warmupBlueBubblesInboundDedupe } from "./inbound-dedupe.js"; -import { asRecord, normalizeWebhookMessage } from "./monitor-normalize.js"; -import { processMessage } from "./monitor-processing.js"; -import type { WebhookTarget } from "./monitor-shared.js"; - -// When the gateway is down, restarting, or wedged, inbound webhook POSTs from -// BB Server fail with ECONNRESET/ECONNREFUSED. BB's WebhookService does not -// retry, and its MessagePoller only re-fires webhooks on BB-side reconnect -// events (Messages.app / APNs), not on webhook-receiver recovery. Without a -// recovery pass, messages delivered during outage windows are permanently -// lost. See #66721 for design discussion and experimental validation. - -const DEFAULT_MAX_AGE_MINUTES = 120; -const MAX_MAX_AGE_MINUTES = 12 * 60; -const DEFAULT_PER_RUN_LIMIT = 50; -const MAX_PER_RUN_LIMIT = 500; -const DEFAULT_FIRST_RUN_LOOKBACK_MINUTES = 30; -const DEFAULT_MAX_FAILURE_RETRIES = 10; -const MAX_MAX_FAILURE_RETRIES = 1_000; -// Defense-in-depth bound: a runaway retry map (e.g., a storm of unique -// failing GUIDs) should not balloon the cursor file unboundedly. When the -// map exceeds this size, we keep only the highest-count entries (the ones -// closest to being given up) and drop the rest. Realistic backlogs stay -// well under this; the bound exists to cap pathological growth. -const MAX_FAILURE_RETRY_MAP_SIZE = 5_000; -const FETCH_TIMEOUT_MS = 15_000; - -export type BlueBubblesCatchupConfig = { - enabled?: boolean; - maxAgeMinutes?: number; - perRunLimit?: number; - firstRunLookbackMinutes?: number; - /** - * Per-message retry ceiling. After this many consecutive failed - * `processMessage` attempts against the same GUID, catchup logs a WARN - * and force-advances the cursor past the wedged message instead of - * holding it indefinitely. Defaults to 10. Clamped to [1, 1000]. - */ - maxFailureRetries?: number; -}; - -export type BlueBubblesCatchupSummary = { - querySucceeded: boolean; - replayed: number; - skippedFromMe: number; - skippedPreCursor: number; - /** - * Messages whose GUID was already recorded as "given up" from a previous - * run (count >= `maxFailureRetries`). These are skipped without calling - * `processMessage` again. Lets the cursor continue advancing past the - * wedged message on the next sweep while avoiding another failed attempt. - */ - skippedGivenUp: number; - failed: number; - /** - * Messages that crossed the `maxFailureRetries` ceiling ON THIS RUN. - * Each transition triggers a WARN log line. Already-given-up messages - * in subsequent runs count under `skippedGivenUp`, not here. Lets - * operators distinguish fresh give-up events from steady-state skips. - */ - givenUp: number; - cursorBefore: number | null; - cursorAfter: number; - windowStartMs: number; - windowEndMs: number; - fetchedCount: number; -}; - -export type BlueBubblesCatchupCursor = { - lastSeenMs: number; - updatedAt: number; - /** - * Per-GUID failure counter, preserved across runs. Two states: - * - `1 <= count < maxFailureRetries`: the GUID is still retrying and - * continues to hold the cursor back. - * - `count >= maxFailureRetries`: catchup has "given up" on the GUID. - * The message is skipped on sight (no `processMessage` attempt) and - * the GUID no longer holds the cursor. The entry stays in the map - * until the cursor naturally advances past the message's timestamp - * (at which point the message stops appearing in queries entirely). - * - * A successful `processMessage` removes the entry. Optional on the - * persisted shape so older cursor files without this field load cleanly. - */ - failureRetries?: Record; -}; - -function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): string { - // Explicit OPENCLAW_STATE_DIR overrides take precedence (including - // per-test mkdtemp dirs in this module's test suite). - if (env.OPENCLAW_STATE_DIR?.trim()) { - return resolveStateDir(env); - } - // Default test isolation: per-pid tmpdir, no bleed into real ~/.openclaw. - // Use resolvePreferredOpenClawTmpDir + string concat (mirrors - // inbound-dedupe) so this doesn't trip the tmpdir-path-guard test that - // flags dynamic template-literal suffixes on os.tmpdir() paths. - if (env.VITEST || env.NODE_ENV === "test") { - const name = "openclaw-vitest-" + process.pid; - return path.join(resolvePreferredOpenClawTmpDir(), name); - } - // Canonical OpenClaw state dir: honors `~` expansion + legacy/new - // fallback. Sharing this resolver with inbound-dedupe is what guarantees - // the catchup cursor and the dedupe state always live under the same - // root, so a replayed GUID is recognized by the dedupe after catchup - // re-feeds the message through processMessage. - return resolveStateDir(env); -} - -function resolveCursorFilePath(accountId: string): string { - // Match inbound-dedupe's file layout: readable prefix + short hash so - // account IDs that only differ by filesystem-unsafe characters do not - // collapse onto the same file. - const safePrefix = accountId.replace(/[^a-zA-Z0-9_-]/g, "_") || "account"; - const hash = createHash("sha256").update(accountId, "utf8").digest("hex").slice(0, 12); - return path.join( - resolveStateDirFromEnv(), - "bluebubbles", - "catchup", - `${safePrefix}__${hash}.json`, - ); -} - -function sanitizeFailureRetriesInput(raw: unknown): Record { - // Older cursor files don't carry this field; also guard against - // hand-edited JSON or future shape drift. Drop any entry whose count is - // not a finite positive integer so downstream arithmetic stays sound. - if (!raw || typeof raw !== "object") { - return {}; - } - const out: Record = {}; - for (const [guid, count] of Object.entries(raw as Record)) { - if (!guid || typeof guid !== "string") { - continue; - } - if (typeof count !== "number" || !Number.isFinite(count) || count <= 0) { - continue; - } - out[guid] = Math.floor(count); - } - return out; -} - -export async function loadBlueBubblesCatchupCursor( - accountId: string, -): Promise { - const filePath = resolveCursorFilePath(accountId); - const { value } = await readJsonFileWithFallback(filePath, null); - if (!value || typeof value !== "object") { - return null; - } - if (typeof value.lastSeenMs !== "number" || !Number.isFinite(value.lastSeenMs)) { - return null; - } - const failureRetries = sanitizeFailureRetriesInput(value.failureRetries); - const hasRetries = Object.keys(failureRetries).length > 0; - // Keep the shape consistent with what the writer emits: only carry the - // `failureRetries` key when there's something to retry. Old cursor files - // without the field continue to round-trip to the same shape. - return { - lastSeenMs: value.lastSeenMs, - updatedAt: typeof value.updatedAt === "number" ? value.updatedAt : 0, - ...(hasRetries ? { failureRetries } : {}), - }; -} - -export async function saveBlueBubblesCatchupCursor( - accountId: string, - lastSeenMs: number, - failureRetries?: Record, -): Promise { - const filePath = resolveCursorFilePath(accountId); - const sanitized = sanitizeFailureRetriesInput(failureRetries); - const hasRetries = Object.keys(sanitized).length > 0; - const cursor: BlueBubblesCatchupCursor = { - lastSeenMs, - updatedAt: Date.now(), - // Only emit the field when non-empty so unrelated cursor writes from - // the happy path don't bloat the cursor file with `"failureRetries": {}`. - ...(hasRetries ? { failureRetries: sanitized } : {}), - }; - await writeJsonFileAtomically(filePath, cursor); -} - -/** - * Bound the retry map so a pathological storm of unique failing GUIDs - * cannot grow the cursor file without limit. Keeps the `maxSize` entries - * with the highest counts (closest to give-up) when over the bound. - * - * The map is already scoped to "currently failing, still-retrying" GUIDs - * and prunes on every run (entries not observed in the fetched window are - * dropped), so this is a defense-in-depth cap, not the primary pruning - * mechanism. - */ -function capFailureRetriesMap( - map: Record, - maxSize: number, -): Record { - const entries = Object.entries(map); - if (entries.length <= maxSize) { - return map; - } - // Sort by count desc; stable tiebreak on guid string so the retained set - // is deterministic across runs (important for cursor-file diffing during - // debugging). - entries.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); - const capped: Record = {}; - for (let i = 0; i < maxSize; i++) { - const [guid, count] = entries[i]; - capped[guid] = count; - } - return capped; -} - -type FetchOpts = { - baseUrl: string; - password: string; - allowPrivateNetwork: boolean; - timeoutMs?: number; -}; - -export type BlueBubblesCatchupFetchResult = { - resolved: boolean; - messages: Array>; -}; - -export async function fetchBlueBubblesMessagesSince( - sinceMs: number, - limit: number, - opts: FetchOpts, -): Promise { - const client = createBlueBubblesClientFromParts({ - baseUrl: opts.baseUrl, - password: opts.password, - allowPrivateNetwork: opts.allowPrivateNetwork, - timeoutMs: opts.timeoutMs ?? FETCH_TIMEOUT_MS, - }); - try { - const res = await client.request({ - method: "POST", - path: "/api/v1/message/query", - body: { - limit, - sort: "ASC", - after: sinceMs, - // `with` mirrors what bb-catchup.sh uses and what the normal webhook - // payload carries, so normalizeWebhookMessage has the same fields to - // read during replay as it does on live dispatch. - with: ["chat", "chat.participants", "attachment"], - }, - timeoutMs: opts.timeoutMs ?? FETCH_TIMEOUT_MS, - }); - if (!res.ok) { - return { resolved: false, messages: [] }; - } - const json = (await res.json().catch(() => null)) as { data?: unknown } | null; - if (!json || !Array.isArray(json.data)) { - return { resolved: false, messages: [] }; - } - const messages: Array> = []; - for (const entry of json.data) { - const rec = asRecord(entry); - if (rec) { - messages.push(rec); - } - } - return { resolved: true, messages }; - } catch { - return { resolved: false, messages: [] }; - } -} - -function clampCatchupConfig(raw?: BlueBubblesCatchupConfig) { - const maxAgeMinutes = Math.min( - Math.max(raw?.maxAgeMinutes ?? DEFAULT_MAX_AGE_MINUTES, 1), - MAX_MAX_AGE_MINUTES, - ); - const perRunLimit = Math.min( - Math.max(raw?.perRunLimit ?? DEFAULT_PER_RUN_LIMIT, 1), - MAX_PER_RUN_LIMIT, - ); - const firstRunLookbackMinutes = Math.min( - Math.max(raw?.firstRunLookbackMinutes ?? DEFAULT_FIRST_RUN_LOOKBACK_MINUTES, 1), - MAX_MAX_AGE_MINUTES, - ); - const maxFailureRetries = Math.min( - Math.max(Math.floor(raw?.maxFailureRetries ?? DEFAULT_MAX_FAILURE_RETRIES), 1), - MAX_MAX_FAILURE_RETRIES, - ); - return { - maxAgeMs: maxAgeMinutes * 60_000, - perRunLimit, - firstRunLookbackMs: firstRunLookbackMinutes * 60_000, - maxFailureRetries, - }; -} - -export type RunBlueBubblesCatchupDeps = { - fetchMessages?: typeof fetchBlueBubblesMessagesSince; - processMessageFn?: typeof processMessage; - now?: () => number; - log?: (message: string) => void; - error?: (message: string) => void; -}; - -/** - * Fetch and replay BlueBubbles messages delivered since the persisted - * catchup cursor, feeding each through the same `processMessage` pipeline - * live webhooks use. Safe to call on every gateway startup: replays that - * collide with #66230's inbound dedupe cache are dropped there, so a - * message already processed via live webhook will not be processed twice. - * - * Returns the run summary, or `null` when disabled or aborted before the - * first query. - * - * Concurrent calls for the same accountId are coalesced into a single - * in-flight run via a module-level singleflight map. Without this, a - * fire-and-forget trigger (monitor.ts) combined with an overlapping - * webhook-target re-registration could race: two runs would read the - * same cursor, compute divergent `nextCursorMs` values, and the last - * writer could regress the cursor — causing repeated replay of the same - * backlog on every subsequent startup. - */ -const inFlightCatchups = new Map>(); - -export function runBlueBubblesCatchup( - target: WebhookTarget, - deps: RunBlueBubblesCatchupDeps = {}, -): Promise { - const accountId = target.account.accountId; - const existing = inFlightCatchups.get(accountId); - if (existing) { - return existing; - } - const runPromise = runBlueBubblesCatchupInner(target, deps).finally(() => { - inFlightCatchups.delete(accountId); - }); - inFlightCatchups.set(accountId, runPromise); - return runPromise; -} - -async function runBlueBubblesCatchupInner( - target: WebhookTarget, - deps: RunBlueBubblesCatchupDeps, -): Promise { - const raw = (target.account.config as { catchup?: BlueBubblesCatchupConfig }).catchup; - if (raw?.enabled === false) { - return null; - } - - const now = deps.now ?? (() => Date.now()); - const log = deps.log ?? target.runtime.log; - const error = deps.error ?? target.runtime.error; - const fetchFn = deps.fetchMessages ?? fetchBlueBubblesMessagesSince; - const procFn = deps.processMessageFn ?? processMessage; - const accountId = target.account.accountId; - - const { maxAgeMs, perRunLimit, firstRunLookbackMs, maxFailureRetries } = clampCatchupConfig(raw); - const nowMs = now(); - const existing = await loadBlueBubblesCatchupCursor(accountId).catch(() => null); - const cursorBefore = existing?.lastSeenMs ?? null; - const prevRetries = existing?.failureRetries ?? {}; - - // Catchup runs once per gateway startup (called from monitor.ts after - // webhook target registration). We deliberately do NOT short-circuit on - // a "ran recently" gate, because catchup is the only mechanism that - // recovers messages dropped during the gateway-down window. A short - // gap (e.g. <30s) between two startups can still have lost messages in - // the middle, and skipping the second startup's catchup would lose - // them permanently. The bounded query (perRunLimit, maxAge) and the - // inbound-dedupe cache from #66230 cap the cost of running the query - // every startup. - - const earliestAllowed = nowMs - maxAgeMs; - // A future-dated cursor (clock rollback via NTP correction or manual - // adjust) is unusable: querying with `after` set to a future timestamp - // would return zero records, and saving `nowMs` as the new cursor would - // permanently skip any real messages missed in the - // [earliestAllowed, nowMs] window. Treat it as if no cursor exists and - // fall through to the firstRun lookback path; the inbound-dedupe cache - // from #66230 handles any overlap with already-processed messages, and - // saving cursor = nowMs at the end of the run repairs the cursor. - const cursorIsUsable = existing !== null && existing.lastSeenMs <= nowMs; - // First-run (and recovered-future-cursor) lookback is also clamped to - // the maxAge ceiling so a config with `maxAgeMinutes: 5, - // firstRunLookbackMinutes: 30` doesn't silently exceed the operator's - // stated lookback cap on first startup. - const windowStartMs = cursorIsUsable - ? Math.max(existing.lastSeenMs, earliestAllowed) - : Math.max(nowMs - firstRunLookbackMs, earliestAllowed); - - let baseUrl: string; - let password: string; - let allowPrivateNetwork = false; - try { - ({ baseUrl, password, allowPrivateNetwork } = resolveBlueBubblesServerAccount({ - serverUrl: target.account.baseUrl, - password: target.account.config.password, - accountId, - cfg: target.config, - })); - } catch (err) { - error?.(`[${accountId}] BlueBubbles catchup: cannot resolve server account: ${String(err)}`); - return null; - } - - // Ensure legacy→hashed dedupe file migration runs and the on-disk store - // is warm before we replay. Without this, an upgrade from a version that - // used the old `${safe}.json` naming to the current `${safe}__${hash}.json` - // would start with an empty dedupe cache and re-dispatch every message in - // the catchup window — producing duplicate replies. - await warmupBlueBubblesInboundDedupe(accountId).catch((err) => { - error?.(`[${accountId}] BlueBubbles catchup: dedupe warmup failed: ${String(err)}`); - }); - - const { resolved, messages } = await fetchFn(windowStartMs, perRunLimit, { - baseUrl, - password, - allowPrivateNetwork, - }); - - const summary: BlueBubblesCatchupSummary = { - querySucceeded: resolved, - replayed: 0, - skippedFromMe: 0, - skippedPreCursor: 0, - skippedGivenUp: 0, - failed: 0, - givenUp: 0, - cursorBefore, - cursorAfter: nowMs, - windowStartMs, - windowEndMs: nowMs, - fetchedCount: messages.length, - }; - - if (!resolved) { - // Leave cursor unchanged so the next run retries the same window. - error?.(`[${accountId}] BlueBubbles catchup: message-query failed; cursor unchanged`); - return summary; - } - - // Track the earliest timestamp where `processMessage` threw *and* the - // failing message has not yet crossed the per-GUID retry ceiling, so we - // never advance the cursor past a retryable failure. Normalize failures - // (the record didn't yield a usable NormalizedWebhookMessage) are - // treated as permanent skips and do NOT block cursor advance — those - // payloads are unlikely to ever normalize on retry, and blocking on - // them would wedge catchup forever. Given-up messages (count >= max) - // also do NOT contribute here; see `skippedGivenUp` below. - let earliestProcessFailureTs: number | null = null; - // Track the latest fetched message timestamp regardless of fate, so a - // truncated query (fetchedCount === perRunLimit) can advance the cursor - // exactly to the page boundary. Without this, the unfetched tail past - // the cap is permanently unreachable. - let latestFetchedTs = windowStartMs; - // Next-run retry map. Built from scratch each run so entries for GUIDs - // that didn't appear in this fetch are dropped (the cursor has - // advanced past them and they will never be queried again). Entries we - // do carry forward encode two states via the stored count: - // - `1 <= count < maxFailureRetries`: still-retrying, holds cursor. - // - `count >= maxFailureRetries`: given-up, skipped on sight without - // another `processMessage` attempt. Preserving the count is what - // keeps the give-up state sticky across runs when an earlier - // still-retrying failure is holding the cursor and the given-up - // message keeps reappearing in the query window. - const nextRetries: Record = {}; - - for (const rec of messages) { - // Defense in depth: the server-side `after:` filter should already - // exclude pre-cursor messages, but guard here against BB API variants - // that return inclusive-of-boundary data. - const ts = typeof rec.dateCreated === "number" ? rec.dateCreated : 0; - if (ts > 0 && ts > latestFetchedTs) { - latestFetchedTs = ts; - } - if (ts > 0 && ts <= windowStartMs) { - summary.skippedPreCursor++; - continue; - } - - // Filter fromMe early so BB's record of our own outbound sends cannot - // enter the inbound pipeline even if normalization would accept them. - if (rec.isFromMe === true || rec.is_from_me === true) { - summary.skippedFromMe++; - continue; - } - - // Skip tapback/reaction/balloon events. These carry an - // `associatedMessageGuid` pointing at the parent text message and - // have a different `guid` of their own. The live webhook path handles - // balloons via the debouncer, which coalesces them with their parent. - // Without debouncing here, replaying a balloon would dispatch it as a - // standalone message — producing a duplicate reply to the parent. - // - // Guard: only skip when `associatedMessageType` is set (tapbacks and - // reactions — e.g., "like", 2000) OR `balloonBundleId` is set (URL - // previews, stickers). iMessage threaded replies use a separate - // `threadOriginatorGuid` field and do NOT set either of these, so - // they pass through for correct catchup replay. - const assocGuid = - typeof rec.associatedMessageGuid === "string" - ? rec.associatedMessageGuid.trim() - : typeof rec.associated_message_guid === "string" - ? rec.associated_message_guid.trim() - : ""; - const assocType = rec.associatedMessageType ?? rec.associated_message_type; - const balloonId = typeof rec.balloonBundleId === "string" ? rec.balloonBundleId.trim() : ""; - if (assocGuid && (assocType != null || balloonId)) { - continue; - } - - const normalized = normalizeWebhookMessage({ type: "new-message", data: rec }); - if (!normalized) { - summary.failed++; - continue; - } - if (normalized.fromMe) { - summary.skippedFromMe++; - continue; - } - - // Prefer the normalized messageId (what the dedupe cache uses) so the - // retry counter and downstream dedupe key agree on identity. Fall - // back to the raw BB `guid` only when normalization didn't supply one. - const retryKey = normalized.messageId ?? (typeof rec.guid === "string" ? rec.guid : ""); - - // Already-given-up GUIDs are skipped without another `processMessage` - // attempt. This is what lets catchup make forward progress through an - // earlier, still-retrying failure while not burning cycles re-running - // a permanently broken message every sweep. - const prevCount = retryKey ? (prevRetries[retryKey] ?? 0) : 0; - if (retryKey && prevCount >= maxFailureRetries) { - summary.skippedGivenUp++; - // Preserve the count so give-up stickiness survives this run. - nextRetries[retryKey] = prevCount; - continue; - } - - try { - await procFn(normalized, target); - summary.replayed++; - // Success clears any accumulated retries for this GUID. Since we - // build `nextRetries` from scratch rather than mutating - // `prevRetries`, simply NOT copying the entry is the clear. (We - // still need this branch so readers understand the lifecycle.) - } catch (err) { - summary.failed++; - const nextCount = prevCount + 1; - if (retryKey && nextCount >= maxFailureRetries) { - // Crossing the ceiling this run: log WARN once and record the - // give-up in the persisted map. Don't contribute to - // `earliestProcessFailureTs` — we're intentionally letting the - // cursor advance past this GUID on the next sweep. - summary.givenUp++; - nextRetries[retryKey] = nextCount; - error?.( - `[${accountId}] BlueBubbles catchup: giving up on guid=${retryKey} ` + - `after ${nextCount} consecutive failures; future sweeps will skip ` + - `this message. timestamp=${ts}: ${String(err)}`, - ); - } else { - // Still retrying: count this failure and hold the cursor so the - // next sweep retries the same window. (retryKey may be empty in - // the unusual case where neither normalizer nor raw payload - // carried a GUID — in that case we hold the cursor but cannot - // increment a counter, matching pre-retry-cap behavior.) - if (retryKey) { - nextRetries[retryKey] = nextCount; - } - if (ts > 0 && (earliestProcessFailureTs === null || ts < earliestProcessFailureTs)) { - earliestProcessFailureTs = ts; - } - error?.( - `[${accountId}] BlueBubbles catchup: processMessage failed (retry ` + - `${nextCount}/${maxFailureRetries}): ${String(err)}`, - ); - } - } - } - - // Compute the new cursor. - // - // - Default: advance to `nowMs` so subsequent runs start from the moment - // this sweep finished (avoiding stuck rescans of a message with - // `dateCreated > nowMs` from minor clock skew between BB host and - // gateway host). - // - On retryable failure (any still-retrying `processMessage` throw, - // where the GUID has NOT crossed `maxFailureRetries`): hold the - // cursor just before the earliest still-retrying failed timestamp so - // the next run retries from there. The inbound-dedupe cache from - // #66230 keeps successfully replayed messages from being re-processed. - // - On give-up (failures that crossed `maxFailureRetries`): the GUID - // is recorded in the persisted retry map with `count >= max` and - // skipped on sight in subsequent runs (without another processMessage - // attempt). Give-up GUIDs intentionally do NOT hold the cursor, so - // the cursor can advance past them naturally — this is what unwedges - // catchup from a permanently malformed message (issue #66870). - // - On truncation (fetched === perRunLimit): advance only to the latest - // fetched timestamp so the next run picks up from the page boundary. - // Otherwise the unfetched tail past the cap (which can be substantial - // during long outages) would be permanently unreachable. - const isTruncated = summary.fetchedCount >= perRunLimit; - let nextCursorMs = nowMs; - if (earliestProcessFailureTs !== null) { - const heldCursor = Math.max(earliestProcessFailureTs - 1, cursorBefore ?? windowStartMs); - nextCursorMs = Math.min(heldCursor, nowMs); - } else if (isTruncated) { - // Use latestFetchedTs (clamped to >= prior cursor and <= nowMs) so the - // next run starts where this page ended. - nextCursorMs = Math.min(Math.max(latestFetchedTs, cursorBefore ?? windowStartMs), nowMs); - } - summary.cursorAfter = nextCursorMs; - // Cap the retry map before writing — defense in depth against a storm - // of unique failing GUIDs ballooning the cursor file. - const retriesToPersist = capFailureRetriesMap(nextRetries, MAX_FAILURE_RETRY_MAP_SIZE); - await saveBlueBubblesCatchupCursor(accountId, nextCursorMs, retriesToPersist).catch((err) => { - error?.(`[${accountId}] BlueBubbles catchup: cursor save failed: ${String(err)}`); - }); - - log?.( - `[${accountId}] BlueBubbles catchup: replayed=${summary.replayed} ` + - `skipped_fromMe=${summary.skippedFromMe} skipped_preCursor=${summary.skippedPreCursor} ` + - `skipped_givenUp=${summary.skippedGivenUp} failed=${summary.failed} ` + - `given_up=${summary.givenUp} fetched=${summary.fetchedCount} ` + - `window_ms=${nowMs - windowStartMs}`, - ); - - // Distinct WARNING when the BB result hits perRunLimit so operators - // know a single startup didn't drain the full backlog. The cursor was - // advanced only to the page boundary above, so the unfetched tail will - // be picked up on the next gateway startup — but if startups are - // infrequent, raising perRunLimit drains larger backlogs in one pass. - if (isTruncated) { - error?.( - `[${accountId}] BlueBubbles catchup: WARNING fetched=${summary.fetchedCount} ` + - `hit perRunLimit=${perRunLimit}; cursor advanced only to page boundary, ` + - `remaining messages will be picked up on next startup. Raise ` + - `channels.bluebubbles...catchup.perRunLimit to drain larger backlogs ` + - `in a single pass.`, - ); - } - - return summary; -} diff --git a/extensions/bluebubbles/src/channel-shared.ts b/extensions/bluebubbles/src/channel-shared.ts deleted file mode 100644 index 9ed9bb4587da..000000000000 --- a/extensions/bluebubbles/src/channel-shared.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describeWebhookAccountSnapshot } from "openclaw/plugin-sdk/account-helpers"; -import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from"; -import { - adaptScopedAccountAccessor, - createScopedChannelConfigAdapter, -} from "openclaw/plugin-sdk/channel-config-helpers"; -import { - listBlueBubblesAccountIds, - type ResolvedBlueBubblesAccount, - resolveBlueBubblesAccount, - resolveDefaultBlueBubblesAccountId, -} from "./accounts.js"; -import { BlueBubblesChannelConfigSchema } from "./config-schema.js"; -import type { ChannelPlugin } from "./runtime-api.js"; -import { normalizeBlueBubblesHandle } from "./targets.js"; - -export const bluebubblesMeta = { - id: "bluebubbles", - label: "BlueBubbles", - selectionLabel: "BlueBubbles (macOS app)", - detailLabel: "BlueBubbles", - docsPath: "/channels/bluebubbles", - docsLabel: "bluebubbles", - blurb: "iMessage via the BlueBubbles mac app + REST API.", - systemImage: "bubble.left.and.text.bubble.right", - aliases: ["bb"], - order: 75, - preferOver: ["imessage"], -}; - -export const bluebubblesCapabilities: ChannelPlugin["capabilities"] = { - chatTypes: ["direct", "group"], - media: true, - tts: { - voice: { - synthesisTarget: "audio-file", - audioFileFormats: ["mp3", "caf", "audio/mpeg", "audio/x-caf"], - // Prefer CAF when the host can pre-transcode (afconvert on macOS). - // The BlueBubbles server otherwise races a CAF→MP3 conversion against - // the upload write completing and silently falls back to a generic - // attachment send when its conversion fails. Pre-encoding to CAF - // bypasses that race so iMessage renders the result as a native voice - // memo bubble (waveform UI) instead of a plain audio attachment. - preferAudioFileFormat: "caf", - }, - }, - reactions: true, - edit: true, - unsend: true, - reply: true, - effects: true, - groupManagement: true, -}; - -export const bluebubblesReload = { configPrefixes: ["channels.bluebubbles"] }; -export const bluebubblesConfigSchema = BlueBubblesChannelConfigSchema; - -export const bluebubblesConfigAdapter = - createScopedChannelConfigAdapter({ - sectionKey: "bluebubbles", - listAccountIds: listBlueBubblesAccountIds, - resolveAccount: adaptScopedAccountAccessor(resolveBlueBubblesAccount), - defaultAccountId: resolveDefaultBlueBubblesAccountId, - clearBaseFields: ["serverUrl", "password", "name", "webhookPath"], - resolveAllowFrom: (account: ResolvedBlueBubblesAccount) => account.config.allowFrom, - formatAllowFrom: (allowFrom) => - formatNormalizedAllowFromEntries({ - allowFrom, - normalizeEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")), - }), - }); - -export function describeBlueBubblesAccount(account: ResolvedBlueBubblesAccount) { - return describeWebhookAccountSnapshot({ - account, - configured: account.configured, - extra: { - baseUrl: account.baseUrl, - }, - }); -} diff --git a/extensions/bluebubbles/src/channel.message-adapter.test.ts b/extensions/bluebubbles/src/channel.message-adapter.test.ts deleted file mode 100644 index e01779e8f034..000000000000 --- a/extensions/bluebubbles/src/channel.message-adapter.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { - createMessageReceiptFromOutboundResults, - verifyChannelMessageAdapterCapabilityProofs, -} from "openclaw/plugin-sdk/channel-message"; -import { afterAll, describe, expect, it, vi } from "vitest"; -import { bluebubblesPlugin } from "./channel.js"; - -const sendMessageBlueBubblesMock = vi.hoisted(() => vi.fn()); -const sendBlueBubblesMediaMock = vi.hoisted(() => vi.fn()); -const resolveBlueBubblesMessageIdMock = vi.hoisted(() => vi.fn()); - -vi.mock("./channel.runtime.js", () => ({ - blueBubblesChannelRuntime: { - sendMessageBlueBubbles: sendMessageBlueBubblesMock, - sendBlueBubblesMedia: sendBlueBubblesMediaMock, - resolveBlueBubblesMessageId: resolveBlueBubblesMessageIdMock, - }, -})); - -afterAll(() => { - vi.doUnmock("./channel.runtime.js"); - vi.resetModules(); -}); - -describe("bluebubbles message adapter", () => { - it("declares durable text, media, and reply target capabilities with receipt proofs", async () => { - sendMessageBlueBubblesMock.mockImplementation( - async (_to: string, _text: string, opts: { replyToMessageGuid?: string } = {}) => ({ - messageId: opts.replyToMessageGuid ? "bb-reply-1" : "bb-text-1", - receipt: createMessageReceiptFromOutboundResults({ - results: [ - { - channel: "bluebubbles", - messageId: opts.replyToMessageGuid ? "bb-reply-1" : "bb-text-1", - }, - ], - kind: "text", - ...(opts.replyToMessageGuid ? { replyToId: opts.replyToMessageGuid } : {}), - }), - }), - ); - sendBlueBubblesMediaMock.mockResolvedValue({ - messageId: "bb-media-1", - receipt: createMessageReceiptFromOutboundResults({ - results: [{ channel: "bluebubbles", messageId: "bb-media-1" }], - kind: "media", - }), - }); - resolveBlueBubblesMessageIdMock.mockReturnValue("guid-reply-1"); - - await expect( - verifyChannelMessageAdapterCapabilityProofs({ - adapterName: "bluebubbles", - adapter: bluebubblesPlugin.message!, - proofs: { - text: async () => { - const result = await bluebubblesPlugin.message?.send?.text?.({ - cfg: {}, - to: "+15551234567", - text: "hello", - }); - expect(sendMessageBlueBubblesMock).toHaveBeenCalledWith("+15551234567", "hello", { - cfg: {}, - accountId: undefined, - replyToMessageGuid: undefined, - }); - expect(result?.receipt.platformMessageIds).toEqual(["bb-text-1"]); - }, - media: async () => { - const result = await bluebubblesPlugin.message?.send?.media?.({ - cfg: {}, - to: "+15551234567", - text: "image", - mediaUrl: "https://example.com/image.png", - }); - expect(sendBlueBubblesMediaMock).toHaveBeenCalledWith( - expect.objectContaining({ - to: "+15551234567", - mediaUrl: "https://example.com/image.png", - caption: "image", - }), - ); - expect(result?.receipt.platformMessageIds).toEqual(["bb-media-1"]); - }, - replyTo: async () => { - const result = await bluebubblesPlugin.message?.send?.text?.({ - cfg: {}, - to: "chat_guid:chat-1", - text: "reply", - replyToId: "short-1", - }); - expect(resolveBlueBubblesMessageIdMock).toHaveBeenCalledWith( - "short-1", - expect.objectContaining({ requireKnownShortId: true }), - ); - expect(sendMessageBlueBubblesMock).toHaveBeenCalledWith("chat_guid:chat-1", "reply", { - cfg: {}, - accountId: undefined, - replyToMessageGuid: "guid-reply-1", - }); - expect(result?.receipt.replyToId).toBe("guid-reply-1"); - }, - messageSendingHooks: async () => { - const beforeSendAttempt = vi.fn(() => "pending-1"); - const afterSendFailure = vi.fn(); - const ctx = { - cfg: {}, - kind: "text" as const, - to: "+15551234567", - text: "hello", - deps: { - bluebubblesMessageLifecycle: { - beforeSendAttempt, - afterSendFailure, - }, - }, - }; - const attemptToken = - await bluebubblesPlugin.message?.send?.lifecycle?.beforeSendAttempt?.(ctx); - await bluebubblesPlugin.message?.send?.lifecycle?.afterSendFailure?.({ - ...ctx, - error: new Error("send failed"), - attemptToken, - }); - expect(beforeSendAttempt).toHaveBeenCalledWith(ctx); - expect(afterSendFailure).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "text", - attemptToken: "pending-1", - error: expect.any(Error), - }), - ); - }, - afterSendSuccess: async () => { - const beforeSendAttempt = vi.fn(() => "pending-1"); - const afterSendSuccess = vi.fn(); - const ctx = { - cfg: {}, - kind: "text" as const, - to: "+15551234567", - text: "hello", - deps: { - bluebubblesMessageLifecycle: { - beforeSendAttempt, - afterSendSuccess, - }, - }, - }; - const attemptToken = - await bluebubblesPlugin.message?.send?.lifecycle?.beforeSendAttempt?.(ctx); - await bluebubblesPlugin.message?.send?.lifecycle?.afterSendSuccess?.({ - ...ctx, - result: { - messageId: "bb-text-1", - receipt: createMessageReceiptFromOutboundResults({ - results: [{ channel: "bluebubbles", messageId: "bb-text-1" }], - kind: "text", - }), - }, - attemptToken, - }); - expect(beforeSendAttempt).toHaveBeenCalledWith(ctx); - expect(afterSendSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "text", - attemptToken: "pending-1", - result: expect.objectContaining({ messageId: "bb-text-1" }), - }), - ); - }, - }, - }), - ).resolves.toEqual( - expect.arrayContaining([ - { capability: "text", status: "verified" }, - { capability: "media", status: "verified" }, - { capability: "replyTo", status: "verified" }, - { capability: "messageSendingHooks", status: "verified" }, - { capability: "afterSendSuccess", status: "verified" }, - ]), - ); - }); -}); diff --git a/extensions/bluebubbles/src/channel.pairing.test.ts b/extensions/bluebubbles/src/channel.pairing.test.ts deleted file mode 100644 index 9d76572c25ab..000000000000 --- a/extensions/bluebubbles/src/channel.pairing.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createBlueBubblesPairingText } from "./pairing.js"; -import type { OpenClawConfig } from "./runtime-api.js"; - -const sendMessageBlueBubblesMock = vi.fn(); -const bluebubblesPairingText = createBlueBubblesPairingText(sendMessageBlueBubblesMock); - -describe("bluebubblesPlugin.pairing.notifyApproval", () => { - beforeEach(() => { - sendMessageBlueBubblesMock.mockReset(); - sendMessageBlueBubblesMock.mockResolvedValue({ messageId: "bb-pairing" }); - }); - - it("preserves accountId when sending pairing approvals", async () => { - const cfg = { - channels: { - bluebubbles: { - accounts: { - work: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }, - }, - } as OpenClawConfig; - - expect(bluebubblesPairingText.normalizeAllowEntry(" bluebubbles:+15551234567 ")).toBe( - "+15551234567", - ); - - await bluebubblesPairingText.notify({ - cfg, - id: "+15551234567", - message: bluebubblesPairingText.message, - accountId: "work", - }); - - expect(sendMessageBlueBubblesMock).toHaveBeenCalledWith( - "+15551234567", - expect.any(String), - expect.objectContaining({ - cfg, - accountId: "work", - }), - ); - }); -}); diff --git a/extensions/bluebubbles/src/channel.runtime.ts b/extensions/bluebubbles/src/channel.runtime.ts deleted file mode 100644 index 7b2b4e2a5a51..000000000000 --- a/extensions/bluebubbles/src/channel.runtime.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { sendBlueBubblesMedia as sendBlueBubblesMediaImpl } from "./media-send.js"; -import { resolveBlueBubblesMessageId as resolveBlueBubblesMessageIdImpl } from "./monitor-reply-cache.js"; -import { - monitorBlueBubblesProvider as monitorBlueBubblesProviderImpl, - resolveWebhookPathFromConfig as resolveWebhookPathFromConfigImpl, -} from "./monitor.js"; -import { probeBlueBubbles as probeBlueBubblesImpl } from "./probe.js"; -import { sendMessageBlueBubbles as sendMessageBlueBubblesImpl } from "./send.js"; - -export type { BlueBubblesProbe } from "./probe.js"; - -export const blueBubblesChannelRuntime = { - sendBlueBubblesMedia: sendBlueBubblesMediaImpl, - resolveBlueBubblesMessageId: resolveBlueBubblesMessageIdImpl, - monitorBlueBubblesProvider: monitorBlueBubblesProviderImpl, - resolveWebhookPathFromConfig: resolveWebhookPathFromConfigImpl, - probeBlueBubbles: probeBlueBubblesImpl, - sendMessageBlueBubbles: sendMessageBlueBubblesImpl, -}; diff --git a/extensions/bluebubbles/src/channel.setup.ts b/extensions/bluebubbles/src/channel.setup.ts deleted file mode 100644 index 8ea6843ebc7e..000000000000 --- a/extensions/bluebubbles/src/channel.setup.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; -import { type ResolvedBlueBubblesAccount } from "./accounts.js"; -import { - bluebubblesCapabilities, - bluebubblesConfigAdapter, - bluebubblesConfigSchema, - bluebubblesMeta, - bluebubblesReload, - describeBlueBubblesAccount, -} from "./channel-shared.js"; -import { blueBubblesSetupAdapter } from "./setup-core.js"; -import { blueBubblesSetupWizard } from "./setup-surface.js"; - -export const bluebubblesSetupPlugin: ChannelPlugin = { - id: "bluebubbles", - meta: { - ...bluebubblesMeta, - aliases: [...bluebubblesMeta.aliases], - preferOver: [...bluebubblesMeta.preferOver], - }, - capabilities: bluebubblesCapabilities, - reload: bluebubblesReload, - configSchema: bluebubblesConfigSchema, - setupWizard: blueBubblesSetupWizard, - config: { - ...bluebubblesConfigAdapter, - isConfigured: (account) => account.configured, - describeAccount: (account) => describeBlueBubblesAccount(account), - }, - setup: blueBubblesSetupAdapter, -}; diff --git a/extensions/bluebubbles/src/channel.status.test.ts b/extensions/bluebubbles/src/channel.status.test.ts deleted file mode 100644 index d6c452f0f488..000000000000 --- a/extensions/bluebubbles/src/channel.status.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "./runtime-api.js"; - -const probeBlueBubblesMock = vi.hoisted(() => vi.fn()); -const cfg: OpenClawConfig = {}; - -vi.mock("./channel.runtime.js", () => ({ - blueBubblesChannelRuntime: { - probeBlueBubbles: probeBlueBubblesMock, - }, -})); - -afterAll(() => { - vi.doUnmock("./channel.runtime.js"); - vi.resetModules(); -}); - -let bluebubblesPlugin: typeof import("./channel.js").bluebubblesPlugin; - -describe("bluebubblesPlugin.status.probeAccount", () => { - beforeAll(async () => { - ({ bluebubblesPlugin } = await import("./channel.js")); - }); - - beforeEach(() => { - probeBlueBubblesMock.mockReset(); - probeBlueBubblesMock.mockResolvedValue({ ok: true, status: 200 }); - }); - - it("auto-enables private-network probes for loopback server URLs", async () => { - await bluebubblesPlugin.status?.probeAccount?.({ - cfg, - account: { - accountId: "default", - enabled: true, - configured: true, - config: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - baseUrl: "http://localhost:1234", - }, - timeoutMs: 5000, - }); - - expect(probeBlueBubblesMock).toHaveBeenCalledWith({ - baseUrl: "http://localhost:1234", - password: "test-password", - timeoutMs: 5000, - allowPrivateNetwork: true, - }); - }); - - it("respects an explicit private-network opt-out for loopback server URLs", async () => { - await bluebubblesPlugin.status?.probeAccount?.({ - cfg, - account: { - accountId: "default", - enabled: true, - configured: true, - config: { - serverUrl: "http://localhost:1234", - password: "test-password", - network: { - dangerouslyAllowPrivateNetwork: false, - }, - }, - baseUrl: "http://localhost:1234", - }, - timeoutMs: 5000, - }); - - expect(probeBlueBubblesMock).toHaveBeenCalledWith({ - baseUrl: "http://localhost:1234", - password: "test-password", - timeoutMs: 5000, - allowPrivateNetwork: false, - }); - }); -}); diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts deleted file mode 100644 index 02b49dbd00b6..000000000000 --- a/extensions/bluebubbles/src/channel.ts +++ /dev/null @@ -1,528 +0,0 @@ -import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; -import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; -import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core"; -import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; -import { - createMessageReceiptFromOutboundResults, - defineChannelMessageAdapter, - type ChannelMessageSendAttemptContext, - type ChannelMessageSendFailureContext, - type ChannelMessageSendSuccessContext, - type ChannelMessageSendResult, - type MessageReceiptPartKind, -} from "openclaw/plugin-sdk/channel-message"; -import { - createOpenGroupPolicyRestrictSendersWarningCollector, - projectAccountWarningCollector, -} from "openclaw/plugin-sdk/channel-policy"; -import { buildProbeChannelStatusSummary } from "openclaw/plugin-sdk/channel-status"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; -import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; -import { - createComputedAccountStatusAdapter, - createDefaultChannelRuntimeState, -} from "openclaw/plugin-sdk/status-helpers"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; -import { - type ResolvedBlueBubblesAccount, - resolveBlueBubblesEffectiveAllowPrivateNetwork, -} from "./accounts.js"; -import { bluebubblesMessageActions } from "./actions.js"; -import { - bluebubblesCapabilities, - bluebubblesConfigAdapter, - bluebubblesConfigSchema, - bluebubblesReload, - describeBlueBubblesAccount, - bluebubblesMeta as meta, -} from "./channel-shared.js"; -import type { BlueBubblesProbe } from "./channel.runtime.js"; -import { createBlueBubblesConversationBindingManager } from "./conversation-bindings.js"; -import { - matchBlueBubblesAcpConversation, - normalizeBlueBubblesAcpConversationId, - resolveBlueBubblesConversationIdFromTarget, -} from "./conversation-id.js"; -import { bluebubblesDoctor } from "./doctor.js"; -import { - resolveBlueBubblesGroupRequireMention, - resolveBlueBubblesGroupToolPolicy, -} from "./group-policy.js"; -import { createBlueBubblesPairingText } from "./pairing.js"; -import type { ChannelAccountSnapshot, ChannelPlugin } from "./runtime-api.js"; -import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js"; -import { resolveBlueBubblesOutboundSessionRoute } from "./session-route.js"; -import { blueBubblesSetupAdapter } from "./setup-core.js"; -import { blueBubblesSetupWizard } from "./setup-surface.js"; -import { collectBlueBubblesStatusIssues } from "./status-issues.js"; -import { - buildBlueBubblesChatContextFromTarget, - extractHandleFromChatGuid, - inferBlueBubblesTargetChatType, - looksLikeBlueBubblesExplicitTargetId, - looksLikeBlueBubblesTargetId, - normalizeBlueBubblesHandle, - normalizeBlueBubblesMessagingTarget, - parseBlueBubblesTarget, -} from "./targets.js"; - -const loadBlueBubblesChannelRuntime = createLazyRuntimeNamedExport( - () => import("./channel.runtime.js"), - "blueBubblesChannelRuntime", -); - -type BlueBubblesRuntime = Awaited>; -type BlueBubblesMediaExtras = { - mediaPath?: string; - mediaBuffer?: Uint8Array; - contentType?: string; - filename?: string; - caption?: string; -}; -type BlueBubblesMessageLifecycleDeps = { - beforeSendAttempt?: (ctx: ChannelMessageSendAttemptContext) => unknown; - afterSendSuccess?: (ctx: ChannelMessageSendSuccessContext) => Promise | void; - afterSendFailure?: (ctx: ChannelMessageSendFailureContext) => Promise | void; -}; - -function resolveBlueBubblesMessageLifecycleDeps( - ctx: - | ChannelMessageSendAttemptContext - | ChannelMessageSendSuccessContext - | ChannelMessageSendFailureContext, -): BlueBubblesMessageLifecycleDeps | undefined { - const candidate = ctx.deps?.bluebubblesMessageLifecycle; - if (!candidate || typeof candidate !== "object") { - return undefined; - } - return candidate as BlueBubblesMessageLifecycleDeps; -} - -function resolveBlueBubblesReplyToMessageGuid(params: { - runtime: BlueBubblesRuntime; - to: string; - replyToId?: string | null; -}): string | undefined { - const rawReplyToId = normalizeOptionalString(params.replyToId) ?? ""; - if (!rawReplyToId) { - return undefined; - } - return ( - params.runtime.resolveBlueBubblesMessageId(rawReplyToId, { - requireKnownShortId: true, - chatContext: buildBlueBubblesChatContextFromTarget(params.to), - }) || undefined - ); -} - -async function sendBlueBubblesTextWithRuntime(params: { - cfg: OpenClawConfig; - to: string; - text: string; - accountId?: string; - replyToId?: string | null; -}) { - const runtime = await loadBlueBubblesChannelRuntime(); - return await runtime.sendMessageBlueBubbles(params.to, params.text, { - cfg: params.cfg, - accountId: params.accountId, - replyToMessageGuid: resolveBlueBubblesReplyToMessageGuid({ - runtime, - to: params.to, - replyToId: params.replyToId, - }), - }); -} - -async function sendBlueBubblesMediaWithRuntime(params: { - cfg: OpenClawConfig; - to: string; - text?: string; - mediaUrl: string; - accountId?: string; - replyToId?: string | null; - audioAsVoice?: boolean; - extras?: BlueBubblesMediaExtras; -}) { - const runtime = await loadBlueBubblesChannelRuntime(); - return await runtime.sendBlueBubblesMedia({ - cfg: params.cfg, - to: params.to, - mediaUrl: params.mediaUrl, - mediaPath: params.extras?.mediaPath, - mediaBuffer: params.extras?.mediaBuffer, - contentType: params.extras?.contentType, - filename: params.extras?.filename, - caption: params.extras?.caption ?? params.text ?? undefined, - replyToId: - resolveBlueBubblesReplyToMessageGuid({ - runtime, - to: params.to, - replyToId: params.replyToId, - }) ?? null, - accountId: params.accountId, - asVoice: params.audioAsVoice === true, - }); -} - -function toBlueBubblesMessageSendResult( - result: { messageId?: string; receipt?: ChannelMessageSendResult["receipt"] }, - kind: MessageReceiptPartKind, - replyToId?: string | null, -): ChannelMessageSendResult { - const receipt = - result.receipt ?? - createMessageReceiptFromOutboundResults({ - results: result.messageId ? [{ channel: "bluebubbles", messageId: result.messageId }] : [], - kind, - ...(replyToId ? { replyToId } : {}), - }); - return { - messageId: result.messageId || receipt.primaryPlatformMessageId, - receipt, - }; -} - -const bluebubblesMessageAdapter = defineChannelMessageAdapter({ - id: "bluebubbles", - durableFinal: { - capabilities: { - text: true, - media: true, - replyTo: true, - messageSendingHooks: true, - afterSendSuccess: true, - }, - }, - send: { - lifecycle: { - beforeSendAttempt: async (ctx) => - await resolveBlueBubblesMessageLifecycleDeps(ctx)?.beforeSendAttempt?.(ctx), - afterSendSuccess: async (ctx) => { - await resolveBlueBubblesMessageLifecycleDeps(ctx)?.afterSendSuccess?.(ctx); - }, - afterSendFailure: async (ctx) => { - await resolveBlueBubblesMessageLifecycleDeps(ctx)?.afterSendFailure?.(ctx); - }, - }, - text: async (ctx) => - toBlueBubblesMessageSendResult( - await sendBlueBubblesTextWithRuntime({ - cfg: ctx.cfg, - to: ctx.to, - text: ctx.text, - accountId: ctx.accountId ?? undefined, - replyToId: ctx.replyToId, - }), - "text", - ctx.replyToId, - ), - media: async (ctx) => - toBlueBubblesMessageSendResult( - await sendBlueBubblesMediaWithRuntime({ - cfg: ctx.cfg, - to: ctx.to, - text: ctx.text, - mediaUrl: ctx.mediaUrl, - accountId: ctx.accountId ?? undefined, - replyToId: ctx.replyToId, - audioAsVoice: ctx.audioAsVoice, - }), - "media", - ctx.replyToId, - ), - }, -}); - -const resolveBlueBubblesDmPolicy = createScopedDmSecurityResolver({ - channelKey: "bluebubbles", - resolvePolicy: (account) => account.config.dmPolicy, - resolveAllowFrom: (account) => account.config.allowFrom, - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")), -}); - -const collectBlueBubblesSecurityWarnings = - createOpenGroupPolicyRestrictSendersWarningCollector({ - resolveGroupPolicy: (account) => account.config.groupPolicy, - defaultGroupPolicy: "allowlist", - surface: "BlueBubbles groups", - openScope: "any member", - groupPolicyPath: "channels.bluebubbles.groupPolicy", - groupAllowFromPath: "channels.bluebubbles.groupAllowFrom", - mentionGated: false, - }); - -export const bluebubblesPlugin: ChannelPlugin = - createChatChannelPlugin({ - base: { - id: "bluebubbles", - meta, - capabilities: bluebubblesCapabilities, - groups: { - resolveRequireMention: resolveBlueBubblesGroupRequireMention, - resolveToolPolicy: resolveBlueBubblesGroupToolPolicy, - }, - reload: bluebubblesReload, - configSchema: bluebubblesConfigSchema, - setupWizard: blueBubblesSetupWizard, - config: { - ...bluebubblesConfigAdapter, - isConfigured: (account) => account.configured, - describeAccount: (account): ChannelAccountSnapshot => describeBlueBubblesAccount(account), - }, - doctor: bluebubblesDoctor, - conversationBindings: { - supportsCurrentConversationBinding: true, - createManager: ({ cfg, accountId }) => - createBlueBubblesConversationBindingManager({ - cfg, - accountId: accountId ?? undefined, - }), - }, - actions: bluebubblesMessageActions, - secrets: { - secretTargetRegistryEntries, - collectRuntimeConfigAssignments, - }, - bindings: { - compileConfiguredBinding: ({ conversationId }) => - normalizeBlueBubblesAcpConversationId(conversationId), - matchInboundConversation: ({ compiledBinding, conversationId }) => - matchBlueBubblesAcpConversation({ - bindingConversationId: compiledBinding.conversationId, - conversationId, - }), - resolveCommandConversation: ({ originatingTo, commandTo, fallbackTo }) => { - const conversationId = - resolveBlueBubblesConversationIdFromTarget(originatingTo ?? "") ?? - resolveBlueBubblesConversationIdFromTarget(commandTo ?? "") ?? - resolveBlueBubblesConversationIdFromTarget(fallbackTo ?? ""); - return conversationId ? { conversationId } : null; - }, - }, - messaging: { - targetPrefixes: ["bluebubbles"], - normalizeTarget: normalizeBlueBubblesMessagingTarget, - inferTargetChatType: ({ to }) => inferBlueBubblesTargetChatType(to), - resolveOutboundSessionRoute: (params) => resolveBlueBubblesOutboundSessionRoute(params), - targetResolver: { - looksLikeId: looksLikeBlueBubblesExplicitTargetId, - hint: "", - resolveTarget: async ({ normalized }) => { - const to = normalizeOptionalString(normalized); - if (!to) { - return null; - } - const chatType = inferBlueBubblesTargetChatType(to); - if (!chatType) { - return null; - } - return { - to, - kind: chatType === "direct" ? "user" : "group", - source: "normalized" as const, - }; - }, - }, - formatTargetDisplay: ({ target, display }) => { - const shouldParseDisplay = (value: string): boolean => { - if (looksLikeBlueBubblesTargetId(value)) { - return true; - } - return /^(bluebubbles:|chat_guid:|chat_id:|chat_identifier:)/i.test(value); - }; - - // Helper to extract a clean handle from any BlueBubbles target format - const extractCleanDisplay = (value: string | undefined): string | null => { - const trimmed = normalizeOptionalString(value); - if (!trimmed) { - return null; - } - try { - const parsed = parseBlueBubblesTarget(trimmed); - if (parsed.kind === "chat_guid") { - const handle = extractHandleFromChatGuid(parsed.chatGuid); - if (handle) { - return handle; - } - } - if (parsed.kind === "handle") { - return normalizeBlueBubblesHandle(parsed.to); - } - } catch { - // Fall through - } - // Strip common prefixes and try raw extraction - const stripped = trimmed - .replace(/^bluebubbles:/i, "") - .replace(/^chat_guid:/i, "") - .replace(/^chat_id:/i, "") - .replace(/^chat_identifier:/i, ""); - const handle = extractHandleFromChatGuid(stripped); - if (handle) { - return handle; - } - // Don't return raw chat_guid formats - they contain internal routing info - if (stripped.includes(";-;") || stripped.includes(";+;")) { - return null; - } - return stripped; - }; - - // Try to get a clean display from the display parameter first - const trimmedDisplay = normalizeOptionalString(display); - if (trimmedDisplay) { - if (!shouldParseDisplay(trimmedDisplay)) { - return trimmedDisplay; - } - const cleanDisplay = extractCleanDisplay(trimmedDisplay); - if (cleanDisplay) { - return cleanDisplay; - } - } - - // Fall back to extracting from target - const cleanTarget = extractCleanDisplay(target); - if (cleanTarget) { - return cleanTarget; - } - - // Last resort: return display or target as-is - return normalizeOptionalString(display) || normalizeOptionalString(target) || ""; - }, - }, - setup: blueBubblesSetupAdapter, - status: createComputedAccountStatusAdapter({ - defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID), - collectStatusIssues: collectBlueBubblesStatusIssues, - buildChannelSummary: ({ snapshot }) => - buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }), - probeAccount: async ({ account, timeoutMs }) => - (await loadBlueBubblesChannelRuntime()).probeBlueBubbles({ - baseUrl: account.baseUrl, - password: account.config.password ?? null, - timeoutMs, - allowPrivateNetwork: resolveBlueBubblesEffectiveAllowPrivateNetwork({ - baseUrl: account.baseUrl, - config: account.config, - }), - }), - resolveAccountSnapshot: ({ account, runtime, probe }) => { - const running = runtime?.running ?? false; - const probeOk = probe?.ok; - return { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - extra: { - baseUrl: account.baseUrl, - connected: probeOk ?? running, - }, - }; - }, - }), - gateway: { - startAccount: async (ctx) => { - const runtime = await loadBlueBubblesChannelRuntime(); - const account = ctx.account; - const conversationBindings = createBlueBubblesConversationBindingManager({ - cfg: ctx.cfg, - accountId: ctx.accountId, - }); - const webhookPath = runtime.resolveWebhookPathFromConfig(account.config); - const statusSink = createAccountStatusSink({ - accountId: ctx.accountId, - setStatus: ctx.setStatus, - }); - statusSink({ - baseUrl: account.baseUrl, - }); - ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`); - try { - return await runtime.monitorBlueBubblesProvider({ - account, - config: ctx.cfg, - runtime: ctx.runtime, - abortSignal: ctx.abortSignal, - statusSink, - webhookPath, - }); - } finally { - conversationBindings.stop(); - } - }, - }, - message: bluebubblesMessageAdapter, - }, - security: { - resolveDmPolicy: resolveBlueBubblesDmPolicy, - collectWarnings: projectAccountWarningCollector< - ResolvedBlueBubblesAccount, - { account: ResolvedBlueBubblesAccount } - >(collectBlueBubblesSecurityWarnings), - }, - threading: { - buildToolContext: ({ context, hasRepliedRef }) => ({ - currentChannelId: normalizeOptionalString(context.To), - currentThreadTs: context.ReplyToIdFull ?? context.ReplyToId, - hasRepliedRef, - }), - }, - pairing: { - text: createBlueBubblesPairingText(async (id, message, params) => { - await (await loadBlueBubblesChannelRuntime()).sendMessageBlueBubbles(id, message, params); - }), - }, - outbound: { - base: { - deliveryMode: "direct", - textChunkLimit: 4000, - resolveTarget: ({ to }) => { - const trimmed = normalizeOptionalString(to); - if (!trimmed) { - return { - ok: false, - error: new Error("Delivering to BlueBubbles requires --to "), - }; - } - return { ok: true, to: trimmed }; - }, - }, - attachedResults: { - channel: "bluebubbles", - sendText: async ({ cfg, to, text, accountId, replyToId }) => - await sendBlueBubblesTextWithRuntime({ - cfg, - to, - text, - accountId: accountId ?? undefined, - replyToId, - }), - sendMedia: async (ctx) => { - const { cfg, to, text, mediaUrl, accountId, replyToId, audioAsVoice } = ctx; - if (!mediaUrl) { - throw new Error("BlueBubbles media send requires mediaUrl"); - } - const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as { - mediaPath?: string; - mediaBuffer?: Uint8Array; - contentType?: string; - filename?: string; - caption?: string; - }; - return await sendBlueBubblesMediaWithRuntime({ - cfg, - to, - text, - mediaUrl, - accountId: accountId ?? undefined, - replyToId, - audioAsVoice, - extras: { mediaPath, mediaBuffer, contentType, filename, caption }, - }); - }, - }, - }, - }); diff --git a/extensions/bluebubbles/src/chat.test.ts b/extensions/bluebubbles/src/chat.test.ts deleted file mode 100644 index f8adc9b86fdb..000000000000 --- a/extensions/bluebubbles/src/chat.test.ts +++ /dev/null @@ -1,621 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import "./test-mocks.js"; -import { - addBlueBubblesParticipant, - editBlueBubblesMessage, - leaveBlueBubblesChat, - markBlueBubblesChatRead, - removeBlueBubblesParticipant, - renameBlueBubblesChat, - sendBlueBubblesTyping, - setGroupIconBlueBubbles, - unsendBlueBubblesMessage, -} from "./chat.js"; -import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; -import { installBlueBubblesFetchTestHooks } from "./test-harness.js"; - -const mockFetch = vi.fn(); - -installBlueBubblesFetchTestHooks({ - mockFetch, - privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus), -}); - -describe("chat", () => { - function mockOkTextResponse() { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - } - - function mockTwoOkTextResponses() { - mockOkTextResponse(); - mockOkTextResponse(); - } - - async function expectCalledUrlIncludesPassword(params: { - password: string; - invoke: () => Promise; - }) { - mockOkTextResponse(); - await params.invoke(); - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain(`password=${params.password}`); - } - - async function expectCalledUrlUsesConfigCredentials(params: { - serverHost: string; - password: string; - invoke: (cfg: { - channels: { bluebubbles: { serverUrl: string; password: string } }; - }) => Promise; - }) { - mockOkTextResponse(); - await params.invoke({ - channels: { - bluebubbles: { - serverUrl: `http://${params.serverHost}`, - password: params.password, - }, - }, - }); - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain(params.serverHost); - expect(calledUrl).toContain(`password=${params.password}`); - } - - describe("markBlueBubblesChatRead", () => { - it("does nothing when chatGuid is empty or whitespace", async () => { - for (const chatGuid of ["", " "]) { - await markBlueBubblesChatRead(chatGuid, { - serverUrl: "http://localhost:1234", - password: "test", - }); - } - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it("throws when required credentials are missing", async () => { - await expect(markBlueBubblesChatRead("chat-guid", {})).rejects.toThrow( - "serverUrl is required", - ); - await expect( - markBlueBubblesChatRead("chat-guid", { - serverUrl: "http://localhost:1234", - }), - ).rejects.toThrow("password is required"); - }); - - it("marks chat as read successfully", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await markBlueBubblesChatRead("iMessage;-;+15551234567", { - serverUrl: "http://localhost:1234", - password: "test-password", - }); - - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/read"), - expect.objectContaining({ method: "POST" }), - ); - }); - - it("does not send read receipt when private API is disabled", async () => { - vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false); - - await markBlueBubblesChatRead("iMessage;-;+15551234567", { - serverUrl: "http://localhost:1234", - password: "test-password", - }); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it("includes password in URL query", async () => { - await expectCalledUrlIncludesPassword({ - password: "my-secret", - invoke: () => - markBlueBubblesChatRead("chat-123", { - serverUrl: "http://localhost:1234", - password: "my-secret", - }), - }); - }); - - it("throws on non-ok response", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404, - text: () => Promise.resolve("Chat not found"), - }); - - await expect( - markBlueBubblesChatRead("missing-chat", { - serverUrl: "http://localhost:1234", - password: "test", - }), - ).rejects.toThrow("read failed (404): Chat not found"); - }); - - it("trims chatGuid before using", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await markBlueBubblesChatRead(" chat-with-spaces ", { - serverUrl: "http://localhost:1234", - password: "test", - }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("/api/v1/chat/chat-with-spaces/read"); - expect(calledUrl).not.toContain("%20chat"); - }); - - it("resolves credentials from config", async () => { - await expectCalledUrlUsesConfigCredentials({ - serverHost: "config-server:9999", - password: "config-pass", - invoke: (cfg) => - markBlueBubblesChatRead("chat-123", { - cfg, - }), - }); - }); - }); - - describe("sendBlueBubblesTyping", () => { - it("does nothing when chatGuid is empty or whitespace", async () => { - for (const chatGuid of ["", " "]) { - await sendBlueBubblesTyping(chatGuid, true, { - serverUrl: "http://localhost:1234", - password: "test", - }); - } - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it("throws when required credentials are missing", async () => { - await expect(sendBlueBubblesTyping("chat-guid", true, {})).rejects.toThrow( - "serverUrl is required", - ); - await expect( - sendBlueBubblesTyping("chat-guid", true, { - serverUrl: "http://localhost:1234", - }), - ).rejects.toThrow("password is required"); - }); - - it("does not send typing when private API is disabled", async () => { - vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false); - - await sendBlueBubblesTyping("iMessage;-;+15551234567", true, { - serverUrl: "http://localhost:1234", - password: "test", - }); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it("uses POST for start and DELETE for stop", async () => { - mockTwoOkTextResponses(); - - await sendBlueBubblesTyping("iMessage;-;+15551234567", true, { - serverUrl: "http://localhost:1234", - password: "test", - }); - await sendBlueBubblesTyping("iMessage;-;+15551234567", false, { - serverUrl: "http://localhost:1234", - password: "test", - }); - - expect(mockFetch).toHaveBeenCalledTimes(2); - expect(mockFetch.mock.calls[0][0]).toContain( - "/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing", - ); - expect(mockFetch.mock.calls[0][1].method).toBe("POST"); - expect(mockFetch.mock.calls[1][0]).toContain( - "/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing", - ); - expect(mockFetch.mock.calls[1][1].method).toBe("DELETE"); - }); - - it("includes password in URL query", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesTyping("chat-123", true, { - serverUrl: "http://localhost:1234", - password: "typing-secret", - }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("password=typing-secret"); - }); - - it("throws on non-ok response", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - text: () => Promise.resolve("Internal error"), - }); - - await expect( - sendBlueBubblesTyping("chat-123", true, { - serverUrl: "http://localhost:1234", - password: "test", - }), - ).rejects.toThrow("typing failed (500): Internal error"); - }); - - it("trims chatGuid before using", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesTyping(" trimmed-chat ", true, { - serverUrl: "http://localhost:1234", - password: "test", - }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("/api/v1/chat/trimmed-chat/typing"); - }); - - it("encodes special characters in chatGuid", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesTyping("iMessage;+;group@chat.com", true, { - serverUrl: "http://localhost:1234", - password: "test", - }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("iMessage%3B%2B%3Bgroup%40chat.com"); - }); - - it("resolves credentials from config", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesTyping("chat-123", true, { - cfg: { - channels: { - bluebubbles: { - serverUrl: "http://typing-server:8888", - password: "typing-pass", - }, - }, - }, - }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("typing-server:8888"); - expect(calledUrl).toContain("password=typing-pass"); - }); - }); - - describe("editBlueBubblesMessage", () => { - it("throws when required args are missing", async () => { - await expect(editBlueBubblesMessage("", "updated", {})).rejects.toThrow("messageGuid"); - await expect(editBlueBubblesMessage("message-guid", " ", {})).rejects.toThrow("newText"); - }); - - it("sends edit request with default payload values", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await editBlueBubblesMessage(" message-guid ", " updated text ", { - serverUrl: "http://localhost:1234", - password: "test-password", - }); - - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining("/api/v1/message/message-guid/edit"), - expect.objectContaining({ - method: "POST", - headers: { "Content-Type": "application/json" }, - }), - ); - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body).toEqual({ - editedMessage: "updated text", - backwardsCompatibilityMessage: "Edited to: updated text", - partIndex: 0, - }); - }); - - it("supports custom part index and backwards compatibility message", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await editBlueBubblesMessage("message-guid", "new text", { - serverUrl: "http://localhost:1234", - password: "test-password", - partIndex: 3, - backwardsCompatMessage: "custom-backwards-message", - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.partIndex).toBe(3); - expect(body.backwardsCompatibilityMessage).toBe("custom-backwards-message"); - }); - - it("throws on non-ok response", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 422, - text: () => Promise.resolve("Unprocessable"), - }); - - await expect( - editBlueBubblesMessage("message-guid", "new text", { - serverUrl: "http://localhost:1234", - password: "test-password", - }), - ).rejects.toThrow("edit failed (422): Unprocessable"); - }); - }); - - describe("unsendBlueBubblesMessage", () => { - it("throws when messageGuid is missing", async () => { - await expect(unsendBlueBubblesMessage("", {})).rejects.toThrow("messageGuid"); - }); - - it("sends unsend request with default part index", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await unsendBlueBubblesMessage(" msg-123 ", { - serverUrl: "http://localhost:1234", - password: "test-password", - }); - - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining("/api/v1/message/msg-123/unsend"), - expect.objectContaining({ - method: "POST", - headers: { "Content-Type": "application/json" }, - }), - ); - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.partIndex).toBe(0); - }); - - it("uses custom part index", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await unsendBlueBubblesMessage("msg-123", { - serverUrl: "http://localhost:1234", - password: "test-password", - partIndex: 2, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.partIndex).toBe(2); - }); - }); - - describe("group chat mutation actions", () => { - it("renames chat", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await renameBlueBubblesChat(" chat-guid ", "New Group Name", { - serverUrl: "http://localhost:1234", - password: "test-password", - }); - - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining("/api/v1/chat/chat-guid"), - expect.objectContaining({ method: "PUT" }), - ); - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.displayName).toBe("New Group Name"); - }); - - it("adds and removes participant using matching endpoint", async () => { - mockTwoOkTextResponses(); - - await addBlueBubblesParticipant("chat-guid", "+15551234567", { - serverUrl: "http://localhost:1234", - password: "test-password", - }); - await removeBlueBubblesParticipant("chat-guid", "+15551234567", { - serverUrl: "http://localhost:1234", - password: "test-password", - }); - - expect(mockFetch).toHaveBeenCalledTimes(2); - expect(mockFetch.mock.calls[0][0]).toContain("/api/v1/chat/chat-guid/participant"); - expect(mockFetch.mock.calls[0][1].method).toBe("POST"); - expect(mockFetch.mock.calls[1][0]).toContain("/api/v1/chat/chat-guid/participant"); - expect(mockFetch.mock.calls[1][1].method).toBe("DELETE"); - - const addBody = JSON.parse(mockFetch.mock.calls[0][1].body); - const removeBody = JSON.parse(mockFetch.mock.calls[1][1].body); - expect(addBody.address).toBe("+15551234567"); - expect(removeBody.address).toBe("+15551234567"); - }); - - it("leaves chat without JSON body", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await leaveBlueBubblesChat("chat-guid", { - serverUrl: "http://localhost:1234", - password: "test-password", - }); - - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining("/api/v1/chat/chat-guid/leave"), - expect.objectContaining({ method: "POST" }), - ); - expect(mockFetch.mock.calls[0][1].body).toBeUndefined(); - expect(mockFetch.mock.calls[0][1].headers).toBeUndefined(); - }); - }); - - describe("setGroupIconBlueBubbles", () => { - it("throws when chatGuid is empty", async () => { - await expect( - setGroupIconBlueBubbles("", new Uint8Array([1, 2, 3]), "icon.png", { - serverUrl: "http://localhost:1234", - password: "test", - }), - ).rejects.toThrow("chatGuid"); - }); - - it("throws when buffer is empty", async () => { - await expect( - setGroupIconBlueBubbles("chat-guid", new Uint8Array(0), "icon.png", { - serverUrl: "http://localhost:1234", - password: "test", - }), - ).rejects.toThrow("image buffer"); - }); - - it("throws when required credentials are missing", async () => { - await expect( - setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {}), - ).rejects.toThrow("serverUrl is required"); - await expect( - setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", { - serverUrl: "http://localhost:1234", - }), - ).rejects.toThrow("password is required"); - }); - - it("throws when private API is disabled", async () => { - vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false); - await expect( - setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", { - serverUrl: "http://localhost:1234", - password: "test", - }), - ).rejects.toThrow("requires Private API"); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it("sets group icon successfully", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes - await setGroupIconBlueBubbles("iMessage;-;chat-guid", buffer, "icon.png", { - serverUrl: "http://localhost:1234", - password: "test-password", - contentType: "image/png", - }); - - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining("/api/v1/chat/iMessage%3B-%3Bchat-guid/icon"), - expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ - "Content-Type": expect.stringContaining("multipart/form-data"), - }), - }), - ); - }); - - it("includes password in URL query", async () => { - await expectCalledUrlIncludesPassword({ - password: "my-secret", - invoke: () => - setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", { - serverUrl: "http://localhost:1234", - password: "my-secret", - }), - }); - }); - - it("throws on non-ok response", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - text: () => Promise.resolve("Internal error"), - }); - - await expect( - setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", { - serverUrl: "http://localhost:1234", - password: "test", - }), - ).rejects.toThrow("setGroupIcon failed (500): Internal error"); - }); - - it("trims chatGuid before using", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await setGroupIconBlueBubbles(" chat-with-spaces ", new Uint8Array([1]), "icon.png", { - serverUrl: "http://localhost:1234", - password: "test", - }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("/api/v1/chat/chat-with-spaces/icon"); - expect(calledUrl).not.toContain("%20chat"); - }); - - it("resolves credentials from config", async () => { - await expectCalledUrlUsesConfigCredentials({ - serverHost: "config-server:9999", - password: "config-pass", - invoke: (cfg) => - setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", { - cfg, - }), - }); - }); - - it("includes filename in multipart body", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "custom-icon.jpg", { - serverUrl: "http://localhost:1234", - password: "test", - contentType: "image/jpeg", - }); - - const body = mockFetch.mock.calls[0][1].body as Uint8Array; - const bodyString = new TextDecoder().decode(body); - expect(bodyString).toContain('filename="custom-icon.jpg"'); - expect(bodyString).toContain("image/jpeg"); - }); - }); -}); diff --git a/extensions/bluebubbles/src/chat.ts b/extensions/bluebubbles/src/chat.ts deleted file mode 100644 index edf1bd5235c4..000000000000 --- a/extensions/bluebubbles/src/chat.ts +++ /dev/null @@ -1,305 +0,0 @@ -import crypto from "node:crypto"; -import path from "node:path"; -import { createBlueBubblesClient, type BlueBubblesClient } from "./client.js"; -import { assertMultipartActionOk } from "./multipart.js"; -import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; -import type { OpenClawConfig } from "./runtime-api.js"; - -export type BlueBubblesChatOpts = { - serverUrl?: string; - password?: string; - accountId?: string; - timeoutMs?: number; - cfg?: OpenClawConfig; -}; - -function clientFromOpts(params: BlueBubblesChatOpts): BlueBubblesClient { - return createBlueBubblesClient(params); -} - -function assertPrivateApiEnabled(accountId: string, feature: string): void { - if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) { - throw new Error( - `BlueBubbles ${feature} requires Private API, but it is disabled on the BlueBubbles server.`, - ); - } -} - -function resolvePartIndex(partIndex: number | undefined): number { - return typeof partIndex === "number" ? partIndex : 0; -} - -async function sendBlueBubblesChatEndpointRequest(params: { - chatGuid: string; - opts: BlueBubblesChatOpts; - endpoint: "read" | "typing"; - method: "POST" | "DELETE"; - action: "read" | "typing"; -}): Promise { - const trimmed = params.chatGuid.trim(); - if (!trimmed) { - return; - } - const client = clientFromOpts(params.opts); - if (getCachedBlueBubblesPrivateApiStatus(client.accountId) === false) { - return; - } - const res = await client.request({ - method: params.method, - path: `/api/v1/chat/${encodeURIComponent(trimmed)}/${params.endpoint}`, - timeoutMs: params.opts.timeoutMs, - }); - await assertMultipartActionOk(res, params.action); -} - -async function sendPrivateApiJsonRequest(params: { - opts: BlueBubblesChatOpts; - feature: string; - action: string; - path: string; - method: "POST" | "PUT" | "DELETE"; - payload?: unknown; -}): Promise { - const client = clientFromOpts(params.opts); - assertPrivateApiEnabled(client.accountId, params.feature); - const res = await client.request({ - method: params.method, - path: params.path, - body: params.payload, - timeoutMs: params.opts.timeoutMs, - }); - await assertMultipartActionOk(res, params.action); -} - -export async function markBlueBubblesChatRead( - chatGuid: string, - opts: BlueBubblesChatOpts = {}, -): Promise { - await sendBlueBubblesChatEndpointRequest({ - chatGuid, - opts, - endpoint: "read", - method: "POST", - action: "read", - }); -} - -export async function sendBlueBubblesTyping( - chatGuid: string, - typing: boolean, - opts: BlueBubblesChatOpts = {}, -): Promise { - await sendBlueBubblesChatEndpointRequest({ - chatGuid, - opts, - endpoint: "typing", - method: typing ? "POST" : "DELETE", - action: "typing", - }); -} - -/** - * Edit a message via BlueBubbles API. - * Requires macOS 13 (Ventura) or higher with Private API enabled. - */ -export async function editBlueBubblesMessage( - messageGuid: string, - newText: string, - opts: BlueBubblesChatOpts & { partIndex?: number; backwardsCompatMessage?: string } = {}, -): Promise { - const trimmedGuid = messageGuid.trim(); - if (!trimmedGuid) { - throw new Error("BlueBubbles edit requires messageGuid"); - } - const trimmedText = newText.trim(); - if (!trimmedText) { - throw new Error("BlueBubbles edit requires newText"); - } - - await sendPrivateApiJsonRequest({ - opts, - feature: "edit", - action: "edit", - method: "POST", - path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/edit`, - payload: { - editedMessage: trimmedText, - backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`, - partIndex: resolvePartIndex(opts.partIndex), - }, - }); -} - -/** - * Unsend (retract) a message via BlueBubbles API. - * Requires macOS 13 (Ventura) or higher with Private API enabled. - */ -export async function unsendBlueBubblesMessage( - messageGuid: string, - opts: BlueBubblesChatOpts & { partIndex?: number } = {}, -): Promise { - const trimmedGuid = messageGuid.trim(); - if (!trimmedGuid) { - throw new Error("BlueBubbles unsend requires messageGuid"); - } - - await sendPrivateApiJsonRequest({ - opts, - feature: "unsend", - action: "unsend", - method: "POST", - path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/unsend`, - payload: { partIndex: resolvePartIndex(opts.partIndex) }, - }); -} - -/** - * Rename a group chat via BlueBubbles API. - */ -export async function renameBlueBubblesChat( - chatGuid: string, - displayName: string, - opts: BlueBubblesChatOpts = {}, -): Promise { - const trimmedGuid = chatGuid.trim(); - if (!trimmedGuid) { - throw new Error("BlueBubbles rename requires chatGuid"); - } - - await sendPrivateApiJsonRequest({ - opts, - feature: "renameGroup", - action: "rename", - method: "PUT", - path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}`, - payload: { displayName }, - }); -} - -/** - * Add a participant to a group chat via BlueBubbles API. - */ -export async function addBlueBubblesParticipant( - chatGuid: string, - address: string, - opts: BlueBubblesChatOpts = {}, -): Promise { - const trimmedGuid = chatGuid.trim(); - if (!trimmedGuid) { - throw new Error("BlueBubbles addParticipant requires chatGuid"); - } - const trimmedAddress = address.trim(); - if (!trimmedAddress) { - throw new Error("BlueBubbles addParticipant requires address"); - } - - await sendPrivateApiJsonRequest({ - opts, - feature: "addParticipant", - action: "addParticipant", - method: "POST", - path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`, - payload: { address: trimmedAddress }, - }); -} - -/** - * Remove a participant from a group chat via BlueBubbles API. - */ -export async function removeBlueBubblesParticipant( - chatGuid: string, - address: string, - opts: BlueBubblesChatOpts = {}, -): Promise { - const trimmedGuid = chatGuid.trim(); - if (!trimmedGuid) { - throw new Error("BlueBubbles removeParticipant requires chatGuid"); - } - const trimmedAddress = address.trim(); - if (!trimmedAddress) { - throw new Error("BlueBubbles removeParticipant requires address"); - } - - await sendPrivateApiJsonRequest({ - opts, - feature: "removeParticipant", - action: "removeParticipant", - method: "DELETE", - path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`, - payload: { address: trimmedAddress }, - }); -} - -/** - * Leave a group chat via BlueBubbles API. - */ -export async function leaveBlueBubblesChat( - chatGuid: string, - opts: BlueBubblesChatOpts = {}, -): Promise { - const trimmedGuid = chatGuid.trim(); - if (!trimmedGuid) { - throw new Error("BlueBubbles leaveChat requires chatGuid"); - } - - await sendPrivateApiJsonRequest({ - opts, - feature: "leaveGroup", - action: "leaveChat", - method: "POST", - path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/leave`, - }); -} - -/** - * Set a group chat's icon/photo via BlueBubbles API. - * Requires Private API to be enabled. - */ -export async function setGroupIconBlueBubbles( - chatGuid: string, - buffer: Uint8Array, - filename: string, - opts: BlueBubblesChatOpts & { contentType?: string } = {}, -): Promise { - const trimmedGuid = chatGuid.trim(); - if (!trimmedGuid) { - throw new Error("BlueBubbles setGroupIcon requires chatGuid"); - } - if (!buffer || buffer.length === 0) { - throw new Error("BlueBubbles setGroupIcon requires image buffer"); - } - - const client = clientFromOpts(opts); - assertPrivateApiEnabled(client.accountId, "setGroupIcon"); - - // Build multipart form-data - const boundary = `----BlueBubblesFormBoundary${crypto.randomUUID().replace(/-/g, "")}`; - const parts: Uint8Array[] = []; - const encoder = new TextEncoder(); - - // Sanitize filename to prevent multipart header injection (CWE-93) - const safeFilename = path.basename(filename).replace(/[\r\n"\\]/g, "_") || "icon.png"; - - // Add file field named "icon" as per API spec - parts.push(encoder.encode(`--${boundary}\r\n`)); - parts.push( - encoder.encode(`Content-Disposition: form-data; name="icon"; filename="${safeFilename}"\r\n`), - ); - parts.push( - encoder.encode(`Content-Type: ${opts.contentType ?? "application/octet-stream"}\r\n\r\n`), - ); - parts.push(buffer); - parts.push(encoder.encode("\r\n")); - - // Close multipart body - parts.push(encoder.encode(`--${boundary}--\r\n`)); - - const res = await client.requestMultipart({ - path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/icon`, - boundary, - parts, - timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads - }); - - await assertMultipartActionOk(res, "setGroupIcon"); -} diff --git a/extensions/bluebubbles/src/client.test.ts b/extensions/bluebubbles/src/client.test.ts deleted file mode 100644 index 81bc919d0ffe..000000000000 --- a/extensions/bluebubbles/src/client.test.ts +++ /dev/null @@ -1,604 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import "./test-mocks.js"; -import { - blueBubblesHeaderAuth, - blueBubblesQueryStringAuth, - BlueBubblesClient, - clearBlueBubblesClientCache, - createBlueBubblesClient, - invalidateBlueBubblesClient, - resolveBlueBubblesClientSsrfPolicy, -} from "./client.js"; -import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; -import { setBlueBubblesRuntime } from "./runtime.js"; -import { - createBlueBubblesFetchGuardPassthroughInstaller, - installBlueBubblesFetchTestHooks, -} from "./test-harness.js"; -import { - createBlueBubblesFetchRemoteMediaMock, - createBlueBubblesRuntimeStub, -} from "./test-helpers.js"; -import type { BlueBubblesAttachment } from "./types.js"; -import { _setFetchGuardForTesting } from "./types.js"; - -// --- Test infrastructure --------------------------------------------------- - -const mockFetch = vi.fn(); - -const fetchRemoteMediaMock = createBlueBubblesFetchRemoteMediaMock({ - createHttpError: ({ response }) => new Error(`media fetch failed: HTTP ${response.status}`), -}); - -installBlueBubblesFetchTestHooks({ - mockFetch, - privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus), -}); - -const runtimeStub = createBlueBubblesRuntimeStub(fetchRemoteMediaMock); - -beforeEach(() => { - fetchRemoteMediaMock.mockClear(); - clearBlueBubblesClientCache(); - setBlueBubblesRuntime(runtimeStub); -}); - -afterEach(() => { - clearBlueBubblesClientCache(); -}); - -// --- resolveBlueBubblesClientSsrfPolicy ------------------------------------ - -describe("resolveBlueBubblesClientSsrfPolicy (3-mode policy)", () => { - it("mode 1: user opts in → { allowPrivateNetwork: true } for any hostname", () => { - const result = resolveBlueBubblesClientSsrfPolicy({ - baseUrl: "http://localhost:1234", - allowPrivateNetwork: true, - }); - expect(result.ssrfPolicy).toEqual({ allowPrivateNetwork: true }); - expect(result.trustedHostname).toBe("localhost"); - expect(result.trustedHostnameIsPrivate).toBe(true); - }); - - it("mode 2: private hostname + no opt-out → narrow allowlist { allowedHostnames: [host] }", () => { - const result = resolveBlueBubblesClientSsrfPolicy({ - baseUrl: "http://192.168.1.50:1234", - allowPrivateNetwork: false, - }); - expect(result.ssrfPolicy).toEqual({ allowedHostnames: ["192.168.1.50"] }); - expect(result.trustedHostnameIsPrivate).toBe(true); - }); - - it("mode 2: localhost + no opt-out → narrow allowlist keeps BB reachable without full opt-in", () => { - const result = resolveBlueBubblesClientSsrfPolicy({ - baseUrl: "http://localhost:1234", - allowPrivateNetwork: false, - }); - expect(result.ssrfPolicy).toEqual({ allowedHostnames: ["localhost"] }); - }); - - it("mode 2: public hostname + no opt-in → narrow allowlist for the public host", () => { - const result = resolveBlueBubblesClientSsrfPolicy({ - baseUrl: "https://bb.example.com", - allowPrivateNetwork: false, - }); - expect(result.ssrfPolicy).toEqual({ allowedHostnames: ["bb.example.com"] }); - expect(result.trustedHostnameIsPrivate).toBe(false); - }); - - it("mode 3: private hostname + explicit opt-out → {} (guarded default-deny, honors the opt-out) (aisle #68234)", () => { - // Previously returned `undefined`, which routed through the unguarded - // fetch fallback and effectively bypassed SSRF protection exactly when - // the user had explicitly asked to disable private-network access. - const result = resolveBlueBubblesClientSsrfPolicy({ - baseUrl: "http://192.168.1.50:1234", - allowPrivateNetwork: false, - allowPrivateNetworkConfig: false, - }); - expect(result.ssrfPolicy).toEqual({}); - expect(result.trustedHostnameIsPrivate).toBe(true); - }); - - it("mode 3: unparseable baseUrl → {} (fail-safe guarded, never bypass)", () => { - const result = resolveBlueBubblesClientSsrfPolicy({ - baseUrl: "not a url", - allowPrivateNetwork: false, - }); - expect(result.ssrfPolicy).toEqual({}); - expect(result.trustedHostname).toBeUndefined(); - }); - - it("never returns undefined ssrfPolicy — every mode is guarded (aisle #68234 invariant)", () => { - // This invariant is what closes the SSRF bypass aisle flagged. Any - // refactor that reintroduces `ssrfPolicy: undefined` should break here. - const cases = [ - { baseUrl: "http://localhost:1234", allowPrivateNetwork: true }, - { baseUrl: "http://localhost:1234", allowPrivateNetwork: false }, - { - baseUrl: "http://192.168.1.50:1234", - allowPrivateNetwork: false, - allowPrivateNetworkConfig: false, - }, - { baseUrl: "https://bb.example.com", allowPrivateNetwork: false }, - { baseUrl: "not a url", allowPrivateNetwork: false }, - ]; - for (const c of cases) { - const result = resolveBlueBubblesClientSsrfPolicy(c); - expect(result.ssrfPolicy).toBeDefined(); - } - }); -}); - -// --- Auth strategies ------------------------------------------------------- - -describe("auth strategies", () => { - it("blueBubblesQueryStringAuth sets ?password= on URL", () => { - const strategy = blueBubblesQueryStringAuth("s3cret"); - const url = new URL("http://localhost:1234/api/v1/ping"); - const init: RequestInit = {}; - strategy.decorate({ url, init }); - expect(url.searchParams.get("password")).toBe("s3cret"); - expect(init.headers).toBeUndefined(); - }); - - it("blueBubblesHeaderAuth sets the auth header and leaves URL clean", () => { - const strategy = blueBubblesHeaderAuth("s3cret"); - const url = new URL("http://localhost:1234/api/v1/ping"); - const init: RequestInit = {}; - strategy.decorate({ url, init }); - expect(url.searchParams.has("password")).toBe(false); - expect(new Headers(init.headers).get("X-BB-Password")).toBe("s3cret"); - }); - - it("blueBubblesHeaderAuth accepts a custom header name", () => { - const strategy = blueBubblesHeaderAuth("s3cret", "Authorization"); - const url = new URL("http://localhost:1234/api/v1/ping"); - const init: RequestInit = {}; - strategy.decorate({ url, init }); - expect(new Headers(init.headers).get("Authorization")).toBe("s3cret"); - }); - - it("auth runs on every request made through the client", async () => { - const client = createBlueBubblesClient({ - serverUrl: "http://localhost:1234", - password: "s3cret", - }); - mockFetch.mockImplementation(() => Promise.resolve(new Response("", { status: 200 }))); - await client.ping(); - await client.getServerInfo(); - const calls = mockFetch.mock.calls; - expect(calls).toHaveLength(2); - expect(String(calls[0]?.[0])).toContain("password=s3cret"); - expect(String(calls[1]?.[0])).toContain("password=s3cret"); - }); - - it("swapping to header auth at factory level keeps URL clean", async () => { - const client = createBlueBubblesClient({ - serverUrl: "http://localhost:1234", - password: "s3cret", - authStrategy: blueBubblesHeaderAuth, - }); - mockFetch.mockResolvedValue(new Response("", { status: 200 })); - await client.ping(); - const [calledUrl, calledInit] = mockFetch.mock.calls[0] ?? []; - expect(String(calledUrl)).not.toContain("password="); - const headers = new Headers((calledInit as RequestInit | undefined)?.headers); - expect(headers.get("X-BB-Password")).toBe("s3cret"); - }); - - it("header-auth headers flow through requestMultipart (Greptile #68234 P1)", async () => { - // Before this fix, requestMultipart discarded prepared.init entirely - // and postMultipartFormData built its own hardcoded Content-Type header. - // Under header-auth that silently omitted the auth header on every - // attachment upload and group-icon set. - const client = createBlueBubblesClient({ - serverUrl: "http://localhost:1234", - password: "s3cret", - authStrategy: blueBubblesHeaderAuth, - }); - mockFetch.mockImplementation(() => Promise.resolve(new Response("{}", { status: 200 }))); - await client.requestMultipart({ - path: "/api/v1/chat/chat-guid/icon", - boundary: "----boundary", - parts: [new Uint8Array([1, 2, 3])], - }); - const [, calledInit] = mockFetch.mock.calls[0] ?? []; - const headers = new Headers((calledInit as RequestInit | undefined)?.headers); - expect(headers.get("X-BB-Password")).toBe("s3cret"); - // And the multipart Content-Type must still be set correctly. - expect(headers.get("Content-Type")).toContain("multipart/form-data; boundary=----boundary"); - }); - - it("header-auth headers flow through downloadAttachment fetchImpl (Greptile #68234 P1)", async () => { - // Before this fix, downloadAttachment built prepared.init.headers with - // the auth header but never forwarded it to the fetchImpl callback, - // so header-auth would silently 401 on attachment downloads. - const client = createBlueBubblesClient({ - serverUrl: "http://localhost:1234", - password: "s3cret", - authStrategy: blueBubblesHeaderAuth, - }); - mockFetch.mockImplementation(() => - Promise.resolve( - new Response(Buffer.from([1, 2, 3]), { - status: 200, - headers: { "content-type": "image/png" }, - }), - ), - ); - await client.downloadAttachment({ attachment: { guid: "att-1", mimeType: "image/png" } }); - // fetchRemoteMediaMock delegates to fetchImpl, which calls mockFetch. - const [, calledInit] = mockFetch.mock.calls[0] ?? []; - const headers = new Headers((calledInit as RequestInit | undefined)?.headers); - expect(headers.get("X-BB-Password")).toBe("s3cret"); - }); -}); - -// --- Core request path ----------------------------------------------------- - -describe("client.request — SSRF policy threading", () => { - it("threads the same resolved policy to the SSRF guard on every call", async () => { - const capturedPolicies: unknown[] = []; - const installPassthrough = createBlueBubblesFetchGuardPassthroughInstaller(); - installPassthrough((policy) => { - capturedPolicies.push(policy); - }); - mockFetch.mockImplementation(() => Promise.resolve(new Response("{}", { status: 200 }))); - - // Public hostname with no explicit opt-in → mode 2 (narrow allowlist). - const client = createBlueBubblesClient({ - cfg: { - channels: { - bluebubbles: { - serverUrl: "https://bb.example.com", - password: "s3cret", - }, - }, - } as never, - }); - - await client.ping(); - await client.getServerInfo(); - - // Both calls used the same narrow allowlist policy (mode 2). - expect(capturedPolicies).toHaveLength(2); - expect(capturedPolicies[0]).toEqual({ allowedHostnames: ["bb.example.com"] }); - expect(capturedPolicies[1]).toEqual({ allowedHostnames: ["bb.example.com"] }); - }); - - it("private hostname auto-allows (mode 1) without explicit opt-in — preserves existing behavior", async () => { - const capturedPolicies: unknown[] = []; - const installPassthrough = createBlueBubblesFetchGuardPassthroughInstaller(); - installPassthrough((policy) => { - capturedPolicies.push(policy); - }); - mockFetch.mockImplementation(() => Promise.resolve(new Response("{}", { status: 200 }))); - - // 192.168/16 hostname with no config → resolveBlueBubblesEffectiveAllowPrivateNetwork - // auto-allows (accounts-normalization.ts:98-107) → mode 1. - const client = createBlueBubblesClient({ - serverUrl: "http://192.168.1.50:1234", - password: "s3cret", - }); - - await client.ping(); - await client.getServerInfo(); - - expect(capturedPolicies).toHaveLength(2); - expect(capturedPolicies[0]).toEqual({ allowPrivateNetwork: true }); - expect(capturedPolicies[1]).toEqual({ allowPrivateNetwork: true }); - }); - - it("applies full-open policy when user opts into private networks", async () => { - const capturedPolicies: unknown[] = []; - const installPassthrough = createBlueBubblesFetchGuardPassthroughInstaller(); - installPassthrough((policy) => { - capturedPolicies.push(policy); - }); - mockFetch.mockResolvedValue(new Response("{}", { status: 200 })); - - const client = createBlueBubblesClient({ - cfg: { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "s3cret", - network: { dangerouslyAllowPrivateNetwork: true }, - }, - }, - } as never, - }); - - await client.ping(); - expect(capturedPolicies[0]).toEqual({ allowPrivateNetwork: true }); - }); -}); - -// --- #59722 regression: reactions use same policy as other calls ----------- - -describe("client.react (regression for #59722)", () => { - it("uses the same SSRF policy as every other client request (no asymmetric {} fallback)", async () => { - const capturedPolicies: unknown[] = []; - const installPassthrough = createBlueBubblesFetchGuardPassthroughInstaller(); - installPassthrough((policy) => { - capturedPolicies.push(policy); - }); - mockFetch.mockImplementation(() => Promise.resolve(new Response("{}", { status: 200 }))); - - const client = createBlueBubblesClient({ - serverUrl: "http://localhost:1234", - password: "s3cret", - }); - - // Both should carry the same mode-2 allowlist — before this client existed, - // reactions.ts passed `{}` (empty guard) while attachments.ts passed - // `{ allowedHostnames: [...] }`. The asymmetry is what #59722 reported. - await client.ping(); - await client.react({ - chatGuid: "iMessage;+;+15551234567", - selectedMessageGuid: "msg-1", - reaction: "like", - }); - - expect(capturedPolicies).toHaveLength(2); - // The critical assertion: both calls resolved the SAME policy, no - // `{}` vs `{ allowedHostnames }` asymmetry like before consolidation. - expect(capturedPolicies[0]).toEqual(capturedPolicies[1]); - // Localhost auto-allows (private hostname, no explicit opt-out). - expect(capturedPolicies[1]).toEqual({ allowPrivateNetwork: true }); - }); - - it("sends the reaction payload with the correct shape and method", async () => { - mockFetch.mockResolvedValue(new Response("{}", { status: 200 })); - const client = createBlueBubblesClient({ - serverUrl: "http://localhost:1234", - password: "s3cret", - }); - await client.react({ - chatGuid: "chat-guid", - selectedMessageGuid: "msg-1", - reaction: "love", - partIndex: 2, - }); - - const [calledUrl, calledInit] = mockFetch.mock.calls[0] ?? []; - expect(String(calledUrl)).toContain("/api/v1/message/react"); - const init = calledInit as RequestInit; - expect(init.method).toBe("POST"); - const body = JSON.parse(init.body as string) as Record; - expect(body).toEqual({ - chatGuid: "chat-guid", - selectedMessageGuid: "msg-1", - reaction: "love", - partIndex: 2, - }); - }); -}); - -// --- #34749 regression: downloadAttachment threads policy end-to-end ------- - -describe("client.downloadAttachment (regression for #34749)", () => { - it("threads the client's ssrfPolicy to fetchRemoteMedia", async () => { - mockFetch.mockResolvedValue( - new Response(Buffer.from([1, 2, 3]), { - status: 200, - headers: { "content-type": "image/png" }, - }), - ); - - const client = createBlueBubblesClient({ - serverUrl: "http://localhost:1234", - password: "s3cret", - }); - await client.downloadAttachment({ - attachment: { guid: "att-1", mimeType: "image/png" }, - }); - - expect(fetchRemoteMediaMock).toHaveBeenCalledTimes(1); - const call = fetchRemoteMediaMock.mock.calls[0]?.[0]; - expect(call?.ssrfPolicy).toEqual({ allowPrivateNetwork: true }); - expect(call?.url).toContain("/api/v1/attachment/att-1/download"); - }); - - it("threads the client's ssrfPolicy to the fetchImpl callback (closes #34749 gap)", async () => { - const capturedPolicies: unknown[] = []; - const installPassthrough = createBlueBubblesFetchGuardPassthroughInstaller(); - installPassthrough((policy) => { - capturedPolicies.push(policy); - }); - mockFetch.mockResolvedValue( - new Response(Buffer.from([1, 2, 3]), { - status: 200, - headers: { "content-type": "image/png" }, - }), - ); - - const client = createBlueBubblesClient({ - serverUrl: "http://localhost:1234", - password: "s3cret", - }); - await client.downloadAttachment({ - attachment: { guid: "att-1", mimeType: "image/png" }, - }); - - // fetchImpl ran (the mock runtime delegates to globalThis.fetch via fetchFn), - // which means blueBubblesFetchWithTimeout was called WITH the ssrfPolicy. - // Before this fix, attachments.ts built its fetchImpl without forwarding - // the policy — the guarded path never ran for the actual attachment bytes. - expect(capturedPolicies).toHaveLength(1); - expect(capturedPolicies[0]).toEqual({ allowPrivateNetwork: true }); - }); - - it("throws when attachment guid is missing", async () => { - const client = createBlueBubblesClient({ - serverUrl: "http://localhost:1234", - password: "s3cret", - }); - await expect( - client.downloadAttachment({ attachment: {} as BlueBubblesAttachment }), - ).rejects.toThrow("guid is required"); - }); - - it("surfaces max_bytes error with clear message", async () => { - mockFetch.mockResolvedValue( - new Response(Buffer.alloc(10 * 1024 * 1024), { - status: 200, - headers: { "content-type": "application/octet-stream" }, - }), - ); - const client = createBlueBubblesClient({ - serverUrl: "http://localhost:1234", - password: "s3cret", - }); - await expect( - client.downloadAttachment({ - attachment: { guid: "att-big" }, - maxBytes: 1024, - }), - ).rejects.toThrow(/too large \(limit 1024 bytes\)/); - }); -}); - -// --- Attachment metadata --------------------------------------------------- - -describe("client.getMessageAttachments", () => { - it("fetches and extracts attachment metadata", async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - data: { - attachments: [ - { guid: "att-xyz", transferName: "IMG_0001.JPG", mimeType: "image/jpeg" }, - ], - }, - }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ); - const client = createBlueBubblesClient({ - serverUrl: "http://localhost:1234", - password: "s3cret", - }); - const result = await client.getMessageAttachments({ messageGuid: "msg-1" }); - expect(result).toHaveLength(1); - expect(result[0]?.guid).toBe("att-xyz"); - expect(result[0]?.mimeType).toBe("image/jpeg"); - expect(String(mockFetch.mock.calls[0]?.[0])).toContain("/api/v1/message/msg-1"); - }); - - it("returns [] on non-ok response rather than throwing", async () => { - mockFetch.mockResolvedValue(new Response("not found", { status: 404 })); - const client = createBlueBubblesClient({ - serverUrl: "http://localhost:1234", - password: "s3cret", - }); - const result = await client.getMessageAttachments({ messageGuid: "missing" }); - expect(result).toEqual([]); - }); -}); - -// --- Cache + invalidation -------------------------------------------------- - -describe("client cache", () => { - it("returns the same instance for the same accountId + baseUrl", () => { - const cfg = { - channels: { - bluebubbles: { serverUrl: "http://localhost:1234", password: "s3cret" }, - }, - } as never; - const a = createBlueBubblesClient({ cfg }); - const b = createBlueBubblesClient({ cfg }); - expect(a).toBe(b); - }); - - it("returns a different instance after invalidate", () => { - const cfg = { - channels: { - bluebubbles: { serverUrl: "http://localhost:1234", password: "s3cret" }, - }, - } as never; - const a = createBlueBubblesClient({ cfg }); - invalidateBlueBubblesClient(a.accountId); - const b = createBlueBubblesClient({ cfg }); - expect(a).not.toBe(b); - }); - - it("cache entry is keyed so different serverUrls cannot collide", () => { - const a = createBlueBubblesClient({ - serverUrl: "http://host-a:1234", - password: "s3cret", - }); - invalidateBlueBubblesClient(a.accountId); - const b = createBlueBubblesClient({ - serverUrl: "http://host-b:1234", - password: "s3cret", - }); - expect(b.baseUrl).toBe("http://host-b:1234"); - }); - - it("different authStrategy for the same account + credential rebuilds the client (Greptile #68234 P2)", () => { - // Before this fix the fingerprint keyed only on {baseUrl, password}. - // A second call with a different authStrategy would silently return - // the cached first strategy's client. - const a = createBlueBubblesClient({ - serverUrl: "http://localhost:1234", - password: "s3cret", - // default: blueBubblesQueryStringAuth - }); - const b = createBlueBubblesClient({ - serverUrl: "http://localhost:1234", - password: "s3cret", - authStrategy: blueBubblesHeaderAuth, - }); - expect(a).not.toBe(b); - }); - - it("private-network config changes rebuild the client without explicit invalidation", () => { - const cfg = { - channels: { - bluebubbles: { - serverUrl: "http://192.168.1.50:1234", - password: "s3cret", - network: { dangerouslyAllowPrivateNetwork: true }, - }, - }, - }; - const allowed = createBlueBubblesClient({ cfg: cfg as never }); - expect(allowed.getSsrfPolicy()).toEqual({ allowPrivateNetwork: true }); - - cfg.channels.bluebubbles.network.dangerouslyAllowPrivateNetwork = false; - const denied = createBlueBubblesClient({ cfg: cfg as never }); - - expect(denied).not.toBe(allowed); - expect(denied.getSsrfPolicy()).toEqual({}); - }); -}); - -describe("client construction", () => { - it("throws when serverUrl is missing", () => { - expect(() => createBlueBubblesClient({ password: "s3cret" })).toThrow(/serverUrl is required/); - }); - - it("throws when password is missing", () => { - expect(() => createBlueBubblesClient({ serverUrl: "http://localhost:1234" })).toThrow( - /password is required/, - ); - }); - - it("is a BlueBubblesClient instance and exposes read-only policy", () => { - const client = createBlueBubblesClient({ - serverUrl: "http://localhost:1234", - password: "s3cret", - }); - expect(client).toBeInstanceOf(BlueBubblesClient); - // localhost auto-allows (accounts-normalization.ts) → mode 1. - expect(client.getSsrfPolicy()).toEqual({ allowPrivateNetwork: true }); - expect(client.trustedHostname).toBe("localhost"); - expect(client.trustedHostnameIsPrivate).toBe(true); - expect(client.accountId).toBeTruthy(); - }); -}); - -// Reference unused import so lint doesn't complain while we keep parity with -// the existing test-harness module contract (#68xxx). -void _setFetchGuardForTesting; diff --git a/extensions/bluebubbles/src/client.ts b/extensions/bluebubbles/src/client.ts deleted file mode 100644 index 9d56256b87c5..000000000000 --- a/extensions/bluebubbles/src/client.ts +++ /dev/null @@ -1,582 +0,0 @@ -// BlueBubblesClient — consolidated BB API client. -// -// Resolves the BB server URL, auth material, and SSRF policy ONCE at -// construction, then exposes typed operations that cannot omit any of them. -// -// Designed to replace the scattered pattern of each callsite computing its own -// SsrFPolicy and passing it to `blueBubblesFetchWithTimeout`. Related issues: -// - #34749 image attachments blocked by SSRF guard (localhost) -// - #57181 SSRF blocks BB plugin internal API calls -// - #59722 SSRF allowlist doesn't cover reactions -// - #60715 BB health check fails on LAN/private serverUrl -// - #66869 move `?password=` → header auth (future-proofed via AuthStrategy) - -import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { isBlockedHostnameOrIp, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; -import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; -import { extractAttachments } from "./monitor-normalize.js"; -import { postMultipartFormData } from "./multipart.js"; -import { resolveRequestUrl } from "./request-url.js"; -import type { OpenClawConfig } from "./runtime-api.js"; -import { getBlueBubblesRuntime } from "./runtime.js"; -import { - blueBubblesFetchWithTimeout, - normalizeBlueBubblesServerUrl, - type BlueBubblesAttachment, -} from "./types.js"; - -const DEFAULT_TIMEOUT_MS = 10_000; -const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024; -const DEFAULT_MULTIPART_TIMEOUT_MS = 60_000; - -// --- Auth strategy --------------------------------------------------------- - -/** - * Pluggable authentication for BlueBubbles API requests. Mutates the URL/init - * pair in place before the request is dispatched. - * - * Two built-in strategies are provided: - * - `blueBubblesQueryStringAuth` — today's `?password=...` pattern (default). - * - `blueBubblesHeaderAuth` — header-based auth; flip the default here when - * BB Server ships the header-auth change for #66869. - */ -interface BlueBubblesAuthStrategy { - /** - * Stable identifier for this strategy. Used by the client cache fingerprint - * so two clients for the same account + credential that differ only in auth - * strategy don't silently collapse onto the same cached instance. - * (Greptile #68234 P2) - */ - readonly id: string; - decorate(req: { url: URL; init: RequestInit }): void; -} - -export function blueBubblesQueryStringAuth(password: string): BlueBubblesAuthStrategy { - return { - id: "query-string", - decorate({ url }) { - url.searchParams.set("password", password); - }, - }; -} - -export function blueBubblesHeaderAuth( - password: string, - headerName = "X-BB-Password", -): BlueBubblesAuthStrategy { - return { - id: `header:${headerName}`, - decorate({ init }) { - const headers = new Headers(init.headers ?? undefined); - headers.set(headerName, password); - init.headers = headers; - }, - }; -} - -// --- Policy resolution ----------------------------------------------------- - -function safeExtractHostname(baseUrl: string): string | undefined { - try { - const hostname = new URL(normalizeBlueBubblesServerUrl(baseUrl)).hostname.trim(); - return hostname || undefined; - } catch { - return undefined; - } -} - -/** - * Resolve the BB client's SSRF policy at construction time. Three modes — - * all of which go through `fetchWithSsrFGuard`; we never hand back a policy - * that skips the guard: - * - * 1. `{ allowPrivateNetwork: true }` — user explicitly opted in - * (`network.dangerouslyAllowPrivateNetwork: true`). Private/loopback - * addresses are permitted for this client. - * - * 2. `{ allowedHostnames: [trustedHostname] }` — narrow allowlist. Applied - * when we have a parseable hostname AND the user has not explicitly - * opted out (or the hostname isn't private anyway). This is the case - * that closes #34749, #57181, #59722, #60715 for self-hosted BB on - * private/localhost addresses without requiring a full opt-in. - * - * 3. `{}` — guarded with the default-deny policy. Applied when we can't - * produce a valid allowlist (opt-out on a private hostname, or an - * unparseable baseUrl). Previously returned `undefined` and skipped - * the guard entirely, which was an SSRF bypass when a user explicitly - * opted out of private-network access. Aisle #68234 found this. - * - * Prior to this helper, the logic lived inline in `attachments.ts` and was - * inconsistently replicated across 15+ callsites. Resolving once ensures - * every request from a client instance uses the same policy. - */ -export function resolveBlueBubblesClientSsrfPolicy(params: { - baseUrl: string; - allowPrivateNetwork: boolean; - allowPrivateNetworkConfig?: boolean; -}): { - ssrfPolicy: SsrFPolicy; - trustedHostname?: string; - trustedHostnameIsPrivate: boolean; -} { - const trustedHostname = safeExtractHostname(params.baseUrl); - const trustedHostnameIsPrivate = trustedHostname ? isBlockedHostnameOrIp(trustedHostname) : false; - - if (params.allowPrivateNetwork) { - return { - ssrfPolicy: { allowPrivateNetwork: true }, - trustedHostname, - trustedHostnameIsPrivate, - }; - } - - if ( - trustedHostname && - (params.allowPrivateNetworkConfig !== false || !trustedHostnameIsPrivate) - ) { - return { - ssrfPolicy: { allowedHostnames: [trustedHostname] }, - trustedHostname, - trustedHostnameIsPrivate, - }; - } - - // Mode 3: default-deny guard. Honors an explicit opt-out on a private - // hostname and fails-safe on unparseable URLs. Never undefined. (aisle #68234) - return { ssrfPolicy: {}, trustedHostname, trustedHostnameIsPrivate }; -} - -// --- Client ---------------------------------------------------------------- - -type BlueBubblesClientOptions = { - cfg?: OpenClawConfig; - accountId?: string; - serverUrl?: string; - password?: string; - timeoutMs?: number; - authStrategy?: (password: string) => BlueBubblesAuthStrategy; -}; - -type ClientConstructorParams = { - accountId: string; - baseUrl: string; - password: string; - ssrfPolicy: SsrFPolicy; - trustedHostname: string | undefined; - trustedHostnameIsPrivate: boolean; - defaultTimeoutMs: number; - authStrategy: BlueBubblesAuthStrategy; -}; - -type MediaFetchErrorCode = "max_bytes" | "http_error" | "fetch_failed"; - -function readMediaFetchErrorCode(error: unknown): MediaFetchErrorCode | undefined { - if (!error || typeof error !== "object") { - return undefined; - } - const code = (error as { code?: unknown }).code; - return code === "max_bytes" || code === "http_error" || code === "fetch_failed" - ? code - : undefined; -} - -export class BlueBubblesClient { - readonly accountId: string; - readonly baseUrl: string; - readonly trustedHostname: string | undefined; - readonly trustedHostnameIsPrivate: boolean; - - private readonly password: string; - private readonly ssrfPolicy: SsrFPolicy; - private readonly defaultTimeoutMs: number; - private readonly authStrategy: BlueBubblesAuthStrategy; - - constructor(params: ClientConstructorParams) { - this.accountId = params.accountId; - this.baseUrl = params.baseUrl; - this.password = params.password; - this.ssrfPolicy = params.ssrfPolicy; - this.trustedHostname = params.trustedHostname; - this.trustedHostnameIsPrivate = params.trustedHostnameIsPrivate; - this.defaultTimeoutMs = params.defaultTimeoutMs; - this.authStrategy = params.authStrategy; - } - - /** - * Read the resolved SSRF policy for this client. Exposed primarily for tests - * and diagnostics; production code should never need to inspect it. - */ - getSsrfPolicy(): SsrFPolicy { - return this.ssrfPolicy; - } - - // Build an authorized URL+init pair. Auth is applied exactly once per - // request; the SSRF policy is attached by `request()` below. - private buildAuthorizedRequest(params: { path: string; method: string; init?: RequestInit }): { - url: string; - init: RequestInit; - } { - const normalized = normalizeBlueBubblesServerUrl(this.baseUrl); - const url = new URL(params.path, `${normalized}/`); - const init: RequestInit = { ...params.init, method: params.method }; - this.authStrategy.decorate({ url, init }); - return { url: url.toString(), init }; - } - - /** - * Core request method. All typed operations on the client route through - * this method, which handles auth decoration, SSRF policy, and timeout. - */ - async request(params: { - method: string; - path: string; - body?: unknown; - headers?: Record; - timeoutMs?: number; - }): Promise { - const init: RequestInit = {}; - if (params.headers) { - init.headers = { ...params.headers }; - } - if (params.body !== undefined) { - init.headers = { - "Content-Type": "application/json", - ...(init.headers as Record | undefined), - }; - init.body = JSON.stringify(params.body); - } - const prepared = this.buildAuthorizedRequest({ - path: params.path, - method: params.method, - init, - }); - return await blueBubblesFetchWithTimeout( - prepared.url, - prepared.init, - params.timeoutMs ?? this.defaultTimeoutMs, - this.ssrfPolicy, - ); - } - - /** - * JSON request helper. Returns both the response (for status/headers) and - * parsed body (null on non-ok or parse failure — callers check both). - */ - async requestJson(params: { - method: string; - path: string; - body?: unknown; - timeoutMs?: number; - }): Promise<{ response: Response; data: unknown }> { - const response = await this.request(params); - if (!response.ok) { - return { response, data: null }; - } - const raw: unknown = await response.json().catch(() => null); - return { response, data: raw }; - } - - /** - * Multipart POST (attachment send, group icon set). The caller supplies the - * boundary and body parts; the client handles URL construction, auth, and - * SSRF policy. Timeout defaults to 60s because uploads can be large. - * - * Auth-decorated headers from `prepared.init` are forwarded via `extraHeaders` - * so header-auth strategies keep working on multipart paths. (Greptile #68234 P1) - */ - async requestMultipart(params: { - path: string; - boundary: string; - parts: Uint8Array[]; - timeoutMs?: number; - }): Promise { - const prepared = this.buildAuthorizedRequest({ - path: params.path, - method: "POST", - init: {}, - }); - return await postMultipartFormData({ - url: prepared.url, - boundary: params.boundary, - parts: params.parts, - timeoutMs: params.timeoutMs ?? DEFAULT_MULTIPART_TIMEOUT_MS, - ssrfPolicy: this.ssrfPolicy, - extraHeaders: prepared.init.headers, - }); - } - - // --- Probe operations ---------------------------------------------------- - - /** GET /api/v1/ping — health check. Raw response for status inspection. */ - async ping(params: { timeoutMs?: number } = {}): Promise { - return await this.request({ - method: "GET", - path: "/api/v1/ping", - timeoutMs: params.timeoutMs, - }); - } - - /** GET /api/v1/server/info — server/OS/Private-API metadata. */ - async getServerInfo(params: { timeoutMs?: number } = {}): Promise { - return await this.request({ - method: "GET", - path: "/api/v1/server/info", - timeoutMs: params.timeoutMs, - }); - } - - // --- Reactions (fixes #59722) ------------------------------------------- - - /** - * POST /api/v1/message/react. Uses the same SSRF policy as every other - * operation on this client — closing the gap where `reactions.ts` passed - * `{}` (always guarded, always blocks private IPs) while other callsites - * used mode-aware policies. - */ - async react(params: { - chatGuid: string; - selectedMessageGuid: string; - reaction: string; - partIndex?: number; - timeoutMs?: number; - }): Promise { - return await this.request({ - method: "POST", - path: "/api/v1/message/react", - body: { - chatGuid: params.chatGuid, - selectedMessageGuid: params.selectedMessageGuid, - reaction: params.reaction, - partIndex: typeof params.partIndex === "number" ? params.partIndex : 0, - }, - timeoutMs: params.timeoutMs, - }); - } - - // --- Attachments (fixes #34749) ----------------------------------------- - - /** - * GET /api/v1/message/{guid} to read attachment metadata. BlueBubbles may - * fire `new-message` before attachment indexing completes, so this re-reads - * after a delay. (#65430, #67437) - */ - async getMessageAttachments(params: { - messageGuid: string; - timeoutMs?: number; - }): Promise { - const { response, data } = await this.requestJson({ - method: "GET", - path: `/api/v1/message/${encodeURIComponent(params.messageGuid)}`, - timeoutMs: params.timeoutMs, - }); - if (!response.ok || typeof data !== "object" || data === null) { - return []; - } - const inner = (data as { data?: unknown }).data; - if (typeof inner !== "object" || inner === null) { - return []; - } - return extractAttachments(inner as Record); - } - - /** - * Download an attachment via the channel media fetcher. Unlike the legacy - * helper, the SSRF policy is threaded to BOTH `fetchRemoteMedia` AND the - * `fetchImpl` callback — closing #34749 where the callback silently fell - * back to the unguarded fetch path regardless of the outer policy. - * - * Note: the actual SSRF check still happens upstream in `fetchRemoteMedia`. - * Passing `ssrfPolicy` to `blueBubblesFetchWithTimeout` in the callback - * keeps it in the guarded path if the host needs re-validation (e.g. on a - * BB Server that issues 302 redirects to a different host). - */ - async downloadAttachment(params: { - attachment: BlueBubblesAttachment; - maxBytes?: number; - timeoutMs?: number; - }): Promise<{ buffer: Uint8Array; contentType?: string }> { - const guid = params.attachment.guid?.trim(); - if (!guid) { - throw new Error("BlueBubbles attachment guid is required"); - } - const maxBytes = - typeof params.maxBytes === "number" ? params.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES; - const prepared = this.buildAuthorizedRequest({ - path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`, - method: "GET", - init: {}, - }); - const clientSsrfPolicy = this.ssrfPolicy; - const effectiveTimeoutMs = params.timeoutMs ?? this.defaultTimeoutMs; - // Auth-decorated headers from buildAuthorizedRequest (for header-auth - // strategies) must flow through the fetchImpl callback too, otherwise - // the runtime might dispatch with only its own default headers. Merge - // prepared.init.headers with any headers the runtime supplies; runtime - // headers (typically Range for partial reads) win on conflict. - // (Greptile #68234 P1) - const preparedHeaders = prepared.init.headers; - - try { - const fetched = await getBlueBubblesRuntime().channel.media.fetchRemoteMedia({ - url: prepared.url, - filePathHint: params.attachment.transferName ?? params.attachment.guid ?? "attachment", - maxBytes, - ssrfPolicy: clientSsrfPolicy, - fetchImpl: async (input, init) => { - const mergedHeaders = new Headers(preparedHeaders); - if (init?.headers) { - const runtimeHeaders = new Headers(init.headers); - runtimeHeaders.forEach((value, key) => mergedHeaders.set(key, value)); - } - return await blueBubblesFetchWithTimeout( - resolveRequestUrl(input), - { ...init, method: init?.method ?? "GET", headers: mergedHeaders }, - effectiveTimeoutMs, - clientSsrfPolicy, - ); - }, - }); - return { - buffer: new Uint8Array(fetched.buffer), - contentType: fetched.contentType ?? params.attachment.mimeType ?? undefined, - }; - } catch (error) { - if (readMediaFetchErrorCode(error) === "max_bytes") { - throw new Error(`BlueBubbles attachment too large (limit ${maxBytes} bytes)`, { - cause: error, - }); - } - throw new Error(`BlueBubbles attachment download failed: ${formatErrorMessage(error)}`, { - cause: error, - }); - } - } -} - -// --- Factory and cache ----------------------------------------------------- - -type CachedClientEntry = { - client: BlueBubblesClient; - /** Fingerprint of auth + SSRF-policy inputs — cache hit requires full match. */ - fingerprint: string; -}; -const clientFingerprints = new Map(); - -function buildClientFingerprint(params: { - baseUrl: string; - password: string; - authStrategyId: string; - allowPrivateNetwork: boolean; - allowPrivateNetworkConfig?: boolean; -}): string { - // Keep every construction-time behavior input here. The client stores auth - // and SSRF policy immutably, so config flips must rebuild without requiring - // a process restart or an explicit cache invalidation call. - return JSON.stringify({ - baseUrl: params.baseUrl, - password: params.password, - authStrategyId: params.authStrategyId, - allowPrivateNetwork: params.allowPrivateNetwork, - allowPrivateNetworkConfig: params.allowPrivateNetworkConfig ?? null, - }); -} - -/** - * Get or create a `BlueBubblesClient` for one BB account. The client is cached - * by `accountId` — the next call with the same account AND same {baseUrl, - * password} returns the existing instance. Password or URL change rebuilds. - * Call `invalidateBlueBubblesClient(accountId)` from account config reload - * paths to evict explicitly. - */ -export function createBlueBubblesClient(opts: BlueBubblesClientOptions = {}): BlueBubblesClient { - const resolved = resolveBlueBubblesServerAccount({ - cfg: opts.cfg, - accountId: opts.accountId, - serverUrl: opts.serverUrl, - password: opts.password, - }); - const cacheKey = resolved.accountId || DEFAULT_ACCOUNT_ID; - const authFactory = opts.authStrategy ?? blueBubblesQueryStringAuth; - const authStrategy = authFactory(resolved.password); - const fingerprint = buildClientFingerprint({ - baseUrl: resolved.baseUrl, - password: resolved.password, - authStrategyId: authStrategy.id, - allowPrivateNetwork: resolved.allowPrivateNetwork, - allowPrivateNetworkConfig: resolved.allowPrivateNetworkConfig, - }); - const cached = clientFingerprints.get(cacheKey); - if (cached && cached.fingerprint === fingerprint) { - return cached.client; - } - - const policyResult = resolveBlueBubblesClientSsrfPolicy({ - baseUrl: resolved.baseUrl, - allowPrivateNetwork: resolved.allowPrivateNetwork, - allowPrivateNetworkConfig: resolved.allowPrivateNetworkConfig, - }); - - const client = new BlueBubblesClient({ - accountId: cacheKey, - baseUrl: resolved.baseUrl, - password: resolved.password, - ssrfPolicy: policyResult.ssrfPolicy, - trustedHostname: policyResult.trustedHostname, - trustedHostnameIsPrivate: policyResult.trustedHostnameIsPrivate, - defaultTimeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, - authStrategy, - }); - clientFingerprints.set(cacheKey, { client, fingerprint }); - return client; -} - -/** Evict a cached client by account id. Called from account config reload paths. */ -export function invalidateBlueBubblesClient(accountId?: string): void { - const key = accountId || DEFAULT_ACCOUNT_ID; - clientFingerprints.delete(key); -} - -/** @internal Clear the whole client cache. Test helper. */ -export function clearBlueBubblesClientCache(): void { - clientFingerprints.clear(); -} - -/** - * Build a BlueBubblesClient from a pre-resolved `{baseUrl, password, - * allowPrivateNetwork}` tuple, skipping the account/config resolution path. - * - * Used by low-level helpers (`probe.ts`, `catchup.ts`, `history.ts`, etc.) - * that are called with the resolved tuple rather than a full config bag. - * Migrated callers pass their existing booleans straight through — the - * three-mode policy resolution then runs exactly once here. - * - * Uncached — intended for short-lived callsites. Prefer `createBlueBubblesClient` - * when a `cfg` + `accountId` are available. - */ -export function createBlueBubblesClientFromParts(params: { - baseUrl: string; - password: string; - allowPrivateNetwork: boolean; - allowPrivateNetworkConfig?: boolean; - accountId?: string; - timeoutMs?: number; - authStrategy?: (password: string) => BlueBubblesAuthStrategy; -}): BlueBubblesClient { - const policyResult = resolveBlueBubblesClientSsrfPolicy({ - baseUrl: params.baseUrl, - allowPrivateNetwork: params.allowPrivateNetwork, - allowPrivateNetworkConfig: params.allowPrivateNetworkConfig, - }); - const authFactory = params.authStrategy ?? blueBubblesQueryStringAuth; - return new BlueBubblesClient({ - accountId: params.accountId || DEFAULT_ACCOUNT_ID, - baseUrl: params.baseUrl, - password: params.password, - ssrfPolicy: policyResult.ssrfPolicy, - trustedHostname: policyResult.trustedHostname, - trustedHostnameIsPrivate: policyResult.trustedHostnameIsPrivate, - defaultTimeoutMs: params.timeoutMs ?? DEFAULT_TIMEOUT_MS, - authStrategy: authFactory(params.password), - }); -} diff --git a/extensions/bluebubbles/src/config-apply.ts b/extensions/bluebubbles/src/config-apply.ts deleted file mode 100644 index bcc56a24bae1..000000000000 --- a/extensions/bluebubbles/src/config-apply.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; - -type BlueBubblesConfigPatch = { - serverUrl?: string; - password?: unknown; - webhookPath?: string; -}; - -type AccountEnabledMode = boolean | "preserve-or-true"; -type BlueBubblesAccountEntry = { - enabled?: boolean; - [key: string]: unknown; -}; - -function normalizePatch( - patch: BlueBubblesConfigPatch, - onlyDefinedFields: boolean, -): BlueBubblesConfigPatch { - if (!onlyDefinedFields) { - return patch; - } - const next: BlueBubblesConfigPatch = {}; - if (patch.serverUrl !== undefined) { - next.serverUrl = patch.serverUrl; - } - if (patch.password !== undefined) { - next.password = patch.password; - } - if (patch.webhookPath !== undefined) { - next.webhookPath = patch.webhookPath; - } - return next; -} - -export function applyBlueBubblesConnectionConfig(params: { - cfg: OpenClawConfig; - accountId: string; - patch: BlueBubblesConfigPatch; - onlyDefinedFields?: boolean; - accountEnabled?: AccountEnabledMode; -}): OpenClawConfig { - const patch = normalizePatch(params.patch, params.onlyDefinedFields === true); - if (params.accountId === DEFAULT_ACCOUNT_ID) { - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - bluebubbles: { - ...params.cfg.channels?.bluebubbles, - enabled: true, - ...patch, - }, - }, - }; - } - - const currentAccount = params.cfg.channels?.bluebubbles?.accounts?.[params.accountId] as - | BlueBubblesAccountEntry - | undefined; - const enabled = - params.accountEnabled === "preserve-or-true" - ? (currentAccount?.enabled ?? true) - : (params.accountEnabled ?? true); - - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - bluebubbles: { - ...params.cfg.channels?.bluebubbles, - enabled: true, - accounts: { - ...params.cfg.channels?.bluebubbles?.accounts, - [params.accountId]: { - ...currentAccount, - enabled, - ...patch, - }, - }, - }, - }, - }; -} diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts deleted file mode 100644 index 5ad372b1fcc9..000000000000 --- a/extensions/bluebubbles/src/config-schema.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { - AllowFromListSchema, - buildChannelConfigSchema, - buildCatchallMultiAccountChannelSchema, - DmPolicySchema, - GroupPolicySchema, - MarkdownConfigSchema, - ToolPolicySchema, - requireAllowlistAllowFrom, - requireOpenAllowFrom, -} from "openclaw/plugin-sdk/channel-config-schema"; -import { z } from "openclaw/plugin-sdk/zod"; -import { bluebubblesChannelConfigUiHints } from "./config-ui-hints.js"; -import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js"; - -const bluebubblesActionSchema = z - .object({ - reactions: z.boolean().default(true), - edit: z.boolean().default(true), - unsend: z.boolean().default(true), - reply: z.boolean().default(true), - sendWithEffect: z.boolean().default(true), - renameGroup: z.boolean().default(true), - setGroupIcon: z.boolean().default(true), - addParticipant: z.boolean().default(true), - removeParticipant: z.boolean().default(true), - leaveGroup: z.boolean().default(true), - sendAttachment: z.boolean().default(true), - }) - .optional(); - -const bluebubblesGroupConfigSchema = z.object({ - requireMention: z.boolean().optional(), - tools: ToolPolicySchema, - /** - * Free-form directive appended to the system prompt for every turn that - * handles a message in this group. Use it for per-group persona tweaks or - * behavioral rules (reply-threading, tapback conventions, etc.). - */ - systemPrompt: z.string().optional(), -}); - -const bluebubblesNetworkSchema = z - .object({ - /** Dangerous opt-in for same-host or trusted private/internal BlueBubbles deployments. */ - dangerouslyAllowPrivateNetwork: z.boolean().optional(), - }) - .strict() - .optional(); - -const bluebubblesCatchupSchema = z - .object({ - /** Replay messages delivered while the gateway was unreachable. Defaults to on. */ - enabled: z.boolean().optional(), - /** Hard ceiling on lookback window. Clamped to [1, 720] minutes. */ - maxAgeMinutes: z.number().int().positive().optional(), - /** Upper bound on messages replayed in a single startup pass. Clamped to [1, 500]. */ - perRunLimit: z.number().int().positive().optional(), - /** First-run lookback used when no cursor has been persisted yet. Clamped to [1, 720]. */ - firstRunLookbackMinutes: z.number().int().positive().optional(), - /** - * Consecutive-failure ceiling per message GUID. After this many failed - * processMessage attempts against the same GUID, catchup logs a WARN - * and skips the message on subsequent sweeps (letting the cursor - * advance past a permanently malformed payload). Defaults to 10. - * Clamped to [1, 1000]. - */ - maxFailureRetries: z.number().int().positive().optional(), - }) - .strict() - .optional(); - -const bluebubblesAccountSchema = z - .object({ - name: z.string().optional(), - enabled: z.boolean().optional(), - markdown: MarkdownConfigSchema, - actions: bluebubblesActionSchema, - serverUrl: z.string().optional(), - password: buildSecretInputSchema().optional(), - webhookPath: z.string().optional(), - dmPolicy: DmPolicySchema.optional(), - allowFrom: AllowFromListSchema, - groupAllowFrom: AllowFromListSchema, - groupPolicy: GroupPolicySchema.optional(), - enrichGroupParticipantsFromContacts: z.boolean().optional().default(true), - historyLimit: z.number().int().min(0).optional(), - dmHistoryLimit: z.number().int().min(0).optional(), - textChunkLimit: z.number().int().positive().optional(), - sendTimeoutMs: z.number().int().positive().optional(), - chunkMode: z.enum(["length", "newline"]).optional(), - mediaMaxMb: z.number().int().positive().optional(), - mediaLocalRoots: z.array(z.string()).optional(), - sendReadReceipts: z.boolean().optional(), - network: bluebubblesNetworkSchema, - catchup: bluebubblesCatchupSchema, - blockStreaming: z.boolean().optional(), - /** - * When an inbound reply lands without `replyToBody`/`replyToSender` and the - * in-memory reply cache misses (e.g., multi-instance deployments sharing - * one BlueBubbles account, after process restarts, or after long-lived - * cache eviction), opt in to fetching the original message from the - * BlueBubbles HTTP API as a best-effort fallback. Off by default. - * - * Left as `.optional()` rather than `.optional().default(false)` so that a - * channel-level `channels.bluebubbles.replyContextApiFallback: true` still - * propagates to accounts that omit the field. With a hard per-account - * default, the merge would clobber the channel value with `false` and - * operators would have to duplicate the flag under every `accounts.`. - * (PR #71820 review) - */ - replyContextApiFallback: z.boolean().optional(), - groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(), - coalesceSameSenderDms: z.boolean().optional(), - }) - .superRefine((value, ctx) => { - const serverUrl = value.serverUrl?.trim() ?? ""; - const passwordConfigured = hasConfiguredSecretInput(value.password); - if (serverUrl && !passwordConfigured) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["password"], - message: "password is required when serverUrl is configured", - }); - } - }); - -type BlueBubblesAccountConfig = z.infer; -type BlueBubblesConfigWithAccounts = BlueBubblesAccountConfig & { - accounts?: Record; -}; - -export const BlueBubblesConfigSchema = buildCatchallMultiAccountChannelSchema( - bluebubblesAccountSchema, -) - .safeExtend({ - actions: bluebubblesActionSchema, - }) - .superRefine((value, ctx) => { - const config = value as BlueBubblesConfigWithAccounts; - requireOpenAllowFrom({ - policy: config.dmPolicy, - allowFrom: config.allowFrom, - ctx, - path: ["allowFrom"], - message: - 'channels.bluebubbles.dmPolicy="open" requires channels.bluebubbles.allowFrom to include "*"', - }); - requireAllowlistAllowFrom({ - policy: config.dmPolicy, - allowFrom: config.allowFrom, - ctx, - path: ["allowFrom"], - message: - 'channels.bluebubbles.dmPolicy="allowlist" requires channels.bluebubbles.allowFrom to contain at least one sender ID', - }); - - for (const [accountId, account] of Object.entries(config.accounts ?? {})) { - const effectivePolicy = account.dmPolicy ?? config.dmPolicy; - const effectiveAllowFrom = account.allowFrom ?? config.allowFrom; - requireOpenAllowFrom({ - policy: effectivePolicy, - allowFrom: effectiveAllowFrom, - ctx, - path: ["accounts", accountId, "allowFrom"], - message: - 'channels.bluebubbles.accounts.*.dmPolicy="open" requires channels.bluebubbles.accounts.*.allowFrom (or channels.bluebubbles.allowFrom) to include "*"', - }); - requireAllowlistAllowFrom({ - policy: effectivePolicy, - allowFrom: effectiveAllowFrom, - ctx, - path: ["accounts", accountId, "allowFrom"], - message: - 'channels.bluebubbles.accounts.*.dmPolicy="allowlist" requires channels.bluebubbles.accounts.*.allowFrom (or channels.bluebubbles.allowFrom) to contain at least one sender ID', - }); - } - }); - -export const BlueBubblesChannelConfigSchema = buildChannelConfigSchema(BlueBubblesConfigSchema, { - uiHints: bluebubblesChannelConfigUiHints, -}); diff --git a/extensions/bluebubbles/src/config-ui-hints.ts b/extensions/bluebubbles/src/config-ui-hints.ts deleted file mode 100644 index 29dc5d157fb7..000000000000 --- a/extensions/bluebubbles/src/config-ui-hints.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ChannelConfigUiHint } from "openclaw/plugin-sdk/channel-core"; - -export const bluebubblesChannelConfigUiHints = { - "": { - label: "BlueBubbles", - help: "BlueBubbles channel provider configuration used for Apple messaging bridge integrations. Keep DM policy aligned with your trusted sender model in shared deployments.", - }, - dmPolicy: { - label: "BlueBubbles DM Policy", - help: 'Direct message access control ("pairing" recommended). "open" requires channels.bluebubbles.allowFrom=["*"].', - }, -} satisfies Record; diff --git a/extensions/bluebubbles/src/conversation-bindings.test.ts b/extensions/bluebubbles/src/conversation-bindings.test.ts deleted file mode 100644 index b21f0b7645e5..000000000000 --- a/extensions/bluebubbles/src/conversation-bindings.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; -import { - __testing as sessionBindingTesting, - getSessionBindingService, -} from "openclaw/plugin-sdk/conversation-runtime"; -import { beforeEach, describe, expect, it } from "vitest"; -import { __testing, createBlueBubblesConversationBindingManager } from "./conversation-bindings.js"; - -const baseCfg = { - session: { mainKey: "main", scope: "per-sender" }, -} satisfies OpenClawConfig; - -describe("BlueBubbles conversation bindings", () => { - beforeEach(() => { - sessionBindingTesting.resetSessionBindingAdaptersForTests(); - __testing.resetBlueBubblesConversationBindingsForTests(); - }); - - it("preserves existing metadata when rebinding the same conversation", async () => { - const manager = createBlueBubblesConversationBindingManager({ - cfg: baseCfg, - accountId: "default", - }); - - manager.bindConversation({ - conversationId: "chat-guid-1", - targetKind: "subagent", - targetSessionKey: "agent:main:subagent:child", - metadata: { - agentId: "codex", - label: "child", - boundBy: "system", - }, - }); - - await getSessionBindingService().bind({ - targetSessionKey: "agent:main:subagent:child", - targetKind: "subagent", - conversation: { - channel: "bluebubbles", - accountId: "default", - conversationId: "chat-guid-1", - }, - placement: "current", - metadata: { - label: "child", - }, - }); - - expect( - getSessionBindingService().resolveByConversation({ - channel: "bluebubbles", - accountId: "default", - conversationId: "chat-guid-1", - }), - ).toMatchObject({ - metadata: expect.objectContaining({ - agentId: "codex", - label: "child", - boundBy: "system", - }), - }); - }); -}); diff --git a/extensions/bluebubbles/src/conversation-bindings.ts b/extensions/bluebubbles/src/conversation-bindings.ts deleted file mode 100644 index 303c84155e58..000000000000 --- a/extensions/bluebubbles/src/conversation-bindings.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; -import { - createAccountScopedConversationBindingManager, - resetAccountScopedConversationBindingsForTests, - type AccountScopedConversationBindingManager, - type BindingTargetKind, -} from "openclaw/plugin-sdk/thread-bindings-runtime"; - -type BlueBubblesBindingTargetKind = "subagent" | "acp"; - -type BlueBubblesConversationBindingManager = - AccountScopedConversationBindingManager; - -const BLUEBUBBLES_CONVERSATION_BINDINGS_STATE_KEY = Symbol.for( - "openclaw.bluebubblesConversationBindingsState", -); - -function toSessionBindingTargetKind(raw: BlueBubblesBindingTargetKind): BindingTargetKind { - return raw === "subagent" ? "subagent" : "session"; -} - -function toBlueBubblesTargetKind(raw: BindingTargetKind): BlueBubblesBindingTargetKind { - return raw === "subagent" ? "subagent" : "acp"; -} - -export function createBlueBubblesConversationBindingManager(params: { - accountId?: string; - cfg: OpenClawConfig; -}): BlueBubblesConversationBindingManager { - return createAccountScopedConversationBindingManager({ - channel: "bluebubbles", - cfg: params.cfg, - accountId: params.accountId, - stateKey: BLUEBUBBLES_CONVERSATION_BINDINGS_STATE_KEY, - toStoredTargetKind: toBlueBubblesTargetKind, - toSessionBindingTargetKind, - }); -} - -export const __testing = { - resetBlueBubblesConversationBindingsForTests() { - resetAccountScopedConversationBindingsForTests({ - stateKey: BLUEBUBBLES_CONVERSATION_BINDINGS_STATE_KEY, - }); - }, -}; diff --git a/extensions/bluebubbles/src/conversation-id.ts b/extensions/bluebubbles/src/conversation-id.ts deleted file mode 100644 index 926158750e7c..000000000000 --- a/extensions/bluebubbles/src/conversation-id.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { - extractHandleFromChatGuid, - normalizeBlueBubblesHandle, - parseBlueBubblesTarget, -} from "./targets.js"; - -export function normalizeBlueBubblesAcpConversationId( - conversationId: string, -): { conversationId: string } | null { - const trimmed = conversationId.trim(); - if (!trimmed) { - return null; - } - - try { - const parsed = parseBlueBubblesTarget(trimmed); - if (parsed.kind === "handle") { - const handle = normalizeBlueBubblesHandle(parsed.to); - return handle ? { conversationId: handle } : null; - } - if (parsed.kind === "chat_id") { - return { conversationId: String(parsed.chatId) }; - } - if (parsed.kind === "chat_guid") { - const handle = extractHandleFromChatGuid(parsed.chatGuid); - return { - conversationId: handle || parsed.chatGuid, - }; - } - return { conversationId: parsed.chatIdentifier }; - } catch { - const handle = normalizeBlueBubblesHandle(trimmed); - return handle ? { conversationId: handle } : null; - } -} - -export function matchBlueBubblesAcpConversation(params: { - bindingConversationId: string; - conversationId: string; -}): { conversationId: string; matchPriority: number } | null { - const binding = normalizeBlueBubblesAcpConversationId(params.bindingConversationId); - const conversation = normalizeBlueBubblesAcpConversationId(params.conversationId); - if (!binding || !conversation) { - return null; - } - if (binding.conversationId !== conversation.conversationId) { - return null; - } - return { - conversationId: conversation.conversationId, - matchPriority: 2, - }; -} - -export function resolveBlueBubblesInboundConversationId(params: { - isGroup: boolean; - sender: string; - chatId?: number | null; - chatGuid?: string | null; - chatIdentifier?: string | null; -}): string | undefined { - if (!params.isGroup) { - const sender = normalizeBlueBubblesHandle(params.sender); - return sender || undefined; - } - - const normalized = - (params.chatGuid && normalizeBlueBubblesAcpConversationId(params.chatGuid)?.conversationId) || - (params.chatIdentifier && - normalizeBlueBubblesAcpConversationId(params.chatIdentifier)?.conversationId) || - (params.chatId != null && Number.isFinite(params.chatId) ? String(params.chatId) : ""); - return normalized || undefined; -} - -export function resolveBlueBubblesConversationIdFromTarget(target: string): string | undefined { - return normalizeBlueBubblesAcpConversationId(target)?.conversationId; -} diff --git a/extensions/bluebubbles/src/conversation-route.test.ts b/extensions/bluebubbles/src/conversation-route.test.ts deleted file mode 100644 index f6aefb8c824c..000000000000 --- a/extensions/bluebubbles/src/conversation-route.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; -import { - __testing as sessionBindingTesting, - registerSessionBindingAdapter, -} from "openclaw/plugin-sdk/conversation-runtime"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { resolveBlueBubblesConversationRoute } from "./conversation-route.js"; - -const baseCfg = { - session: { mainKey: "main", scope: "per-sender" }, - agents: { - list: [{ id: "main" }, { id: "codex" }], - }, -} satisfies OpenClawConfig; - -describe("resolveBlueBubblesConversationRoute", () => { - beforeEach(() => { - sessionBindingTesting.resetSessionBindingAdaptersForTests(); - }); - - afterEach(() => { - sessionBindingTesting.resetSessionBindingAdaptersForTests(); - }); - - it("lets runtime BlueBubbles conversation bindings override default routing", () => { - const touch = vi.fn(); - registerSessionBindingAdapter({ - channel: "bluebubbles", - accountId: "default", - listBySession: () => [], - resolveByConversation: (ref) => - ref.conversationId === "+15555550123" - ? { - bindingId: "default:+15555550123", - targetSessionKey: "agent:codex:acp:bound-1", - targetKind: "session", - conversation: { - channel: "bluebubbles", - accountId: "default", - conversationId: "+15555550123", - }, - status: "active", - boundAt: Date.now(), - metadata: { boundBy: "user-1" }, - } - : null, - touch, - }); - - const route = resolveBlueBubblesConversationRoute({ - cfg: baseCfg, - accountId: "default", - isGroup: false, - peerId: "+15555550123", - sender: "+15555550123", - }); - - expect(route.agentId).toBe("codex"); - expect(route.sessionKey).toBe("agent:codex:acp:bound-1"); - expect(route.matchedBy).toBe("binding.channel"); - expect(touch).toHaveBeenCalledWith("default:+15555550123", undefined); - }); -}); diff --git a/extensions/bluebubbles/src/conversation-route.ts b/extensions/bluebubbles/src/conversation-route.ts deleted file mode 100644 index 6bf5927f16d5..000000000000 --- a/extensions/bluebubbles/src/conversation-route.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; -import { - resolveConfiguredBindingRoute, - resolveRuntimeConversationBindingRoute, -} from "openclaw/plugin-sdk/conversation-runtime"; -import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; -import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { resolveBlueBubblesInboundConversationId } from "./conversation-id.js"; - -export function resolveBlueBubblesConversationRoute(params: { - cfg: OpenClawConfig; - accountId: string; - isGroup: boolean; - peerId: string; - sender: string; - chatId?: number | null; - chatGuid?: string | null; - chatIdentifier?: string | null; -}): ReturnType { - let route = resolveAgentRoute({ - cfg: params.cfg, - channel: "bluebubbles", - accountId: params.accountId, - peer: { - kind: params.isGroup ? "group" : "direct", - id: params.peerId, - }, - }); - - const conversationId = resolveBlueBubblesInboundConversationId({ - isGroup: params.isGroup, - sender: params.sender, - chatId: params.chatId, - chatGuid: params.chatGuid, - chatIdentifier: params.chatIdentifier, - }); - if (!conversationId) { - return route; - } - - route = resolveConfiguredBindingRoute({ - cfg: params.cfg, - route, - conversation: { - channel: "bluebubbles", - accountId: params.accountId, - conversationId, - }, - }).route; - - const runtimeRoute = resolveRuntimeConversationBindingRoute({ - route, - conversation: { - channel: "bluebubbles", - accountId: params.accountId, - conversationId, - }, - }); - route = runtimeRoute.route; - if (runtimeRoute.bindingRecord && !runtimeRoute.boundSessionKey) { - logVerbose(`bluebubbles: plugin-bound conversation ${conversationId}`); - } else if (runtimeRoute.boundSessionKey) { - logVerbose( - `bluebubbles: routed via bound conversation ${conversationId} -> ${runtimeRoute.boundSessionKey}`, - ); - } - return route; -} diff --git a/extensions/bluebubbles/src/doctor-contract.ts b/extensions/bluebubbles/src/doctor-contract.ts deleted file mode 100644 index 7debec7d3211..000000000000 --- a/extensions/bluebubbles/src/doctor-contract.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createLegacyPrivateNetworkDoctorContract } from "openclaw/plugin-sdk/ssrf-runtime"; - -const contract = createLegacyPrivateNetworkDoctorContract({ - channelKey: "bluebubbles", -}); - -export const legacyConfigRules = contract.legacyConfigRules; - -export const normalizeCompatibilityConfig = contract.normalizeCompatibilityConfig; diff --git a/extensions/bluebubbles/src/doctor.test.ts b/extensions/bluebubbles/src/doctor.test.ts deleted file mode 100644 index b0fbf90294eb..000000000000 --- a/extensions/bluebubbles/src/doctor.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { bluebubblesDoctor } from "./doctor.js"; - -describe("bluebubbles doctor", () => { - it("normalizes legacy private-network aliases", () => { - const normalize = bluebubblesDoctor.normalizeCompatibilityConfig; - expect(normalize).toBeDefined(); - if (!normalize) { - return; - } - - const result = normalize({ - cfg: { - channels: { - bluebubbles: { - allowPrivateNetwork: true, - accounts: { - default: { - allowPrivateNetwork: false, - }, - }, - }, - }, - } as never, - }); - - expect(result.config.channels?.bluebubbles?.network).toEqual({ - dangerouslyAllowPrivateNetwork: true, - }); - expect( - ( - result.config.channels?.bluebubbles?.accounts?.default as { - network?: { dangerouslyAllowPrivateNetwork?: boolean }; - } - )?.network, - ).toEqual({ - dangerouslyAllowPrivateNetwork: false, - }); - }); -}); diff --git a/extensions/bluebubbles/src/doctor.ts b/extensions/bluebubbles/src/doctor.ts deleted file mode 100644 index c699c6f371df..000000000000 --- a/extensions/bluebubbles/src/doctor.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract"; -import { - legacyConfigRules as BLUEBUBBLES_LEGACY_CONFIG_RULES, - normalizeCompatibilityConfig as normalizeBlueBubblesCompatibilityConfig, -} from "./doctor-contract.js"; - -export const bluebubblesDoctor: ChannelDoctorAdapter = { - legacyConfigRules: BLUEBUBBLES_LEGACY_CONFIG_RULES, - normalizeCompatibilityConfig: normalizeBlueBubblesCompatibilityConfig, -}; diff --git a/extensions/bluebubbles/src/group-policy.ts b/extensions/bluebubbles/src/group-policy.ts deleted file mode 100644 index dd593f89fefe..000000000000 --- a/extensions/bluebubbles/src/group-policy.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { - resolveChannelGroupRequireMention, - resolveChannelGroupToolsPolicy, - type GroupToolPolicyConfig, -} from "openclaw/plugin-sdk/channel-policy"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; - -type BlueBubblesGroupContext = { - cfg: OpenClawConfig; - accountId?: string | null; - groupId?: string | null; - senderId?: string | null; - senderName?: string | null; - senderUsername?: string | null; - senderE164?: string | null; -}; - -export function resolveBlueBubblesGroupRequireMention(params: BlueBubblesGroupContext): boolean { - return resolveChannelGroupRequireMention({ - cfg: params.cfg, - channel: "bluebubbles", - groupId: params.groupId, - accountId: params.accountId, - }); -} - -export function resolveBlueBubblesGroupToolPolicy( - params: BlueBubblesGroupContext, -): GroupToolPolicyConfig | undefined { - return resolveChannelGroupToolsPolicy({ - cfg: params.cfg, - channel: "bluebubbles", - groupId: params.groupId, - accountId: params.accountId, - senderId: params.senderId, - senderName: params.senderName, - senderUsername: params.senderUsername, - senderE164: params.senderE164, - }); -} diff --git a/extensions/bluebubbles/src/history.ts b/extensions/bluebubbles/src/history.ts deleted file mode 100644 index b24af8118544..000000000000 --- a/extensions/bluebubbles/src/history.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; -import { createBlueBubblesClientFromParts } from "./client.js"; -import type { OpenClawConfig } from "./runtime-api.js"; - -type BlueBubblesHistoryEntry = { - sender: string; - body: string; - timestamp?: number; - messageId?: string; -}; - -type BlueBubblesHistoryFetchResult = { - entries: BlueBubblesHistoryEntry[]; - /** - * True when at least one API path returned a recognized response shape. - * False means all attempts failed or returned unusable data. - */ - resolved: boolean; -}; - -type BlueBubblesMessageData = { - guid?: string; - text?: string; - handle_id?: string; - is_from_me?: boolean; - date_created?: number; - date_delivered?: number; - associated_message_guid?: string; - sender?: { - address?: string; - display_name?: string; - }; -}; - -type BlueBubblesChatOpts = { - serverUrl?: string; - password?: string; - accountId?: string; - timeoutMs?: number; - cfg?: OpenClawConfig; -}; - -function resolveAccount(params: BlueBubblesChatOpts) { - return resolveBlueBubblesServerAccount(params); -} - -const MAX_HISTORY_FETCH_LIMIT = 100; -const HISTORY_SCAN_MULTIPLIER = 8; -const MAX_HISTORY_SCAN_MESSAGES = 500; -const MAX_HISTORY_BODY_CHARS = 2_000; - -function clampHistoryLimit(limit: number): number { - if (!Number.isFinite(limit)) { - return 0; - } - const normalized = Math.floor(limit); - if (normalized <= 0) { - return 0; - } - return Math.min(normalized, MAX_HISTORY_FETCH_LIMIT); -} - -function truncateHistoryBody(text: string): string { - if (text.length <= MAX_HISTORY_BODY_CHARS) { - return text; - } - return `${text.slice(0, MAX_HISTORY_BODY_CHARS).trimEnd()}...`; -} - -/** - * Fetch message history from BlueBubbles API for a specific chat. - * This provides the initial backfill for both group chats and DMs. - */ -export async function fetchBlueBubblesHistory( - chatIdentifier: string, - limit: number, - opts: BlueBubblesChatOpts = {}, -): Promise { - const effectiveLimit = clampHistoryLimit(limit); - if (!chatIdentifier.trim() || effectiveLimit <= 0) { - return { entries: [], resolved: true }; - } - - let baseUrl: string; - let password: string; - let allowPrivateNetwork = false; - try { - ({ baseUrl, password, allowPrivateNetwork } = resolveAccount(opts)); - } catch { - return { entries: [], resolved: false }; - } - const client = createBlueBubblesClientFromParts({ - baseUrl, - password, - allowPrivateNetwork, - timeoutMs: opts.timeoutMs ?? 10000, - }); - - // Try different common API patterns for fetching messages - const possiblePaths = [ - `/api/v1/chat/${encodeURIComponent(chatIdentifier)}/messages?limit=${effectiveLimit}&sort=DESC`, - `/api/v1/messages?chatGuid=${encodeURIComponent(chatIdentifier)}&limit=${effectiveLimit}`, - `/api/v1/chat/${encodeURIComponent(chatIdentifier)}/message?limit=${effectiveLimit}`, - ]; - - for (const path of possiblePaths) { - try { - const res = await client.request({ - method: "GET", - path, - timeoutMs: opts.timeoutMs ?? 10000, - }); - - if (!res.ok) { - continue; // Try next path - } - - const data = await res.json().catch(() => null); - if (!data) { - continue; - } - - // Handle different response structures - let messages: unknown[] = []; - if (Array.isArray(data)) { - messages = data; - } else if (data.data && Array.isArray(data.data)) { - messages = data.data; - } else if (data.messages && Array.isArray(data.messages)) { - messages = data.messages; - } else { - continue; - } - - const historyEntries: BlueBubblesHistoryEntry[] = []; - - const maxScannedMessages = Math.min( - Math.max(effectiveLimit * HISTORY_SCAN_MULTIPLIER, effectiveLimit), - MAX_HISTORY_SCAN_MESSAGES, - ); - for (let i = 0; i < messages.length && i < maxScannedMessages; i++) { - const item = messages[i]; - const msg = item as BlueBubblesMessageData; - - // Skip messages without text content - const text = msg.text?.trim(); - if (!text) { - continue; - } - - const sender = msg.is_from_me - ? "me" - : msg.sender?.display_name || msg.sender?.address || msg.handle_id || "Unknown"; - const timestamp = msg.date_created || msg.date_delivered; - - historyEntries.push({ - sender, - body: truncateHistoryBody(text), - timestamp, - messageId: msg.guid, - }); - } - - // Sort by timestamp (oldest first for context) - historyEntries.sort((a, b) => { - const aTime = a.timestamp || 0; - const bTime = b.timestamp || 0; - return aTime - bTime; - }); - - return { - entries: historyEntries.slice(0, effectiveLimit), // Ensure we don't exceed the requested limit - resolved: true, - }; - } catch { - // Continue to next path - continue; - } - } - - // If none of the API paths worked, return empty history - return { entries: [], resolved: false }; -} diff --git a/extensions/bluebubbles/src/inbound-dedupe.test.ts b/extensions/bluebubbles/src/inbound-dedupe.test.ts deleted file mode 100644 index 190ea06d7b3d..000000000000 --- a/extensions/bluebubbles/src/inbound-dedupe.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { - _resetBlueBubblesInboundDedupForTest, - claimBlueBubblesInboundMessage, - commitBlueBubblesCoalescedMessageIds, - resolveBlueBubblesInboundDedupeKey, -} from "./inbound-dedupe.js"; - -async function claimAndFinalize(guid: string | undefined, accountId: string): Promise { - const claim = await claimBlueBubblesInboundMessage({ guid, accountId }); - if (claim.kind === "claimed") { - await claim.finalize(); - } - return claim.kind; -} - -describe("claimBlueBubblesInboundMessage", () => { - beforeEach(() => { - _resetBlueBubblesInboundDedupForTest(); - }); - - it("claims a new guid and rejects committed duplicates", async () => { - expect(await claimAndFinalize("g1", "acc")).toBe("claimed"); - expect(await claimAndFinalize("g1", "acc")).toBe("duplicate"); - }); - - it("scopes dedupe per account", async () => { - expect(await claimAndFinalize("g1", "a")).toBe("claimed"); - expect(await claimAndFinalize("g1", "b")).toBe("claimed"); - }); - - it("reports skip when guid is missing or blank", async () => { - expect((await claimBlueBubblesInboundMessage({ guid: undefined, accountId: "acc" })).kind).toBe( - "skip", - ); - expect((await claimBlueBubblesInboundMessage({ guid: "", accountId: "acc" })).kind).toBe( - "skip", - ); - expect((await claimBlueBubblesInboundMessage({ guid: " ", accountId: "acc" })).kind).toBe( - "skip", - ); - }); - - it("rejects overlong guids to cap on-disk size", async () => { - const huge = "x".repeat(10_000); - expect((await claimBlueBubblesInboundMessage({ guid: huge, accountId: "acc" })).kind).toBe( - "skip", - ); - }); - - it("releases the claim so a later replay can retry after a transient failure", async () => { - const first = await claimBlueBubblesInboundMessage({ guid: "g1", accountId: "acc" }); - expect(first.kind).toBe("claimed"); - if (first.kind === "claimed") { - first.release(); - } - // Released claims should be re-claimable on the next delivery. - expect(await claimAndFinalize("g1", "acc")).toBe("claimed"); - }); -}); - -describe("commitBlueBubblesCoalescedMessageIds", () => { - beforeEach(() => { - _resetBlueBubblesInboundDedupForTest(); - }); - - it("marks every coalesced source messageId as seen so a later replay dedupes", async () => { - // Primary was processed via claim+finalize by the debouncer flush. - expect(await claimAndFinalize("primary", "acc")).toBe("claimed"); - // Secondaries reach dedupe through the bulk-commit path. - await commitBlueBubblesCoalescedMessageIds({ - messageIds: ["secondary-1", "secondary-2"], - accountId: "acc", - }); - // A MessagePoller replay of any individual source event is now a duplicate - // rather than a fresh agent turn — the core bug this helper exists to fix. - expect(await claimAndFinalize("primary", "acc")).toBe("duplicate"); - expect(await claimAndFinalize("secondary-1", "acc")).toBe("duplicate"); - expect(await claimAndFinalize("secondary-2", "acc")).toBe("duplicate"); - }); - - it("scopes coalesced commits per account", async () => { - await commitBlueBubblesCoalescedMessageIds({ - messageIds: ["g1"], - accountId: "a", - }); - // Same messageId under a different account is still claimable. - expect(await claimAndFinalize("g1", "a")).toBe("duplicate"); - expect(await claimAndFinalize("g1", "b")).toBe("claimed"); - }); - - it("skips empty or overlong guids without throwing", async () => { - await commitBlueBubblesCoalescedMessageIds({ - messageIds: ["", " ", "x".repeat(10_000), "valid"], - accountId: "acc", - }); - expect(await claimAndFinalize("valid", "acc")).toBe("duplicate"); - // Overlong guid was skipped by sanitization, not committed. - expect(await claimAndFinalize("x".repeat(10_000), "acc")).toBe("skip"); - }); -}); - -describe("resolveBlueBubblesInboundDedupeKey", () => { - it("returns messageId for new-message events", () => { - expect(resolveBlueBubblesInboundDedupeKey({ messageId: "msg-1" })).toBe("msg-1"); - }); - - it("returns associatedMessageGuid for balloon events", () => { - expect( - resolveBlueBubblesInboundDedupeKey({ - messageId: "balloon-1", - balloonBundleId: "com.apple.messages.URLBalloonProvider", - associatedMessageGuid: "msg-1", - }), - ).toBe("msg-1"); - }); - - it("suffixes key with :updated for updated-message events", () => { - expect( - resolveBlueBubblesInboundDedupeKey({ messageId: "msg-1", eventType: "updated-message" }), - ).toBe("msg-1:updated"); - }); - - it("updated-message and new-message for same GUID produce distinct keys", () => { - const newKey = resolveBlueBubblesInboundDedupeKey({ messageId: "msg-1" }); - const updatedKey = resolveBlueBubblesInboundDedupeKey({ - messageId: "msg-1", - eventType: "updated-message", - }); - expect(newKey).not.toBe(updatedKey); - }); - - it("returns undefined when messageId is missing", () => { - expect(resolveBlueBubblesInboundDedupeKey({})).toBeUndefined(); - }); -}); diff --git a/extensions/bluebubbles/src/inbound-dedupe.ts b/extensions/bluebubbles/src/inbound-dedupe.ts deleted file mode 100644 index b94db99b08d1..000000000000 --- a/extensions/bluebubbles/src/inbound-dedupe.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { createHash } from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; -import { type ClaimableDedupe, createClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe"; -import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; -import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; -import type { NormalizedWebhookMessage } from "./monitor-normalize.js"; - -// BlueBubbles has no sequence/ack in its webhook protocol, and its -// MessagePoller replays its ~1-week lookback window as `new-message` events -// after BB Server restarts or reconnects. Without persistent dedup, the -// gateway can reply to messages that were already handled before a restart -// (see issues #19176, #12053). -// -// TTL matches BB's lookback window so any replay is guaranteed to land on -// a remembered GUID, and the file-backed store survives gateway restarts. -const DEDUP_TTL_MS = 7 * 24 * 60 * 60 * 1_000; -const MEMORY_MAX_SIZE = 5_000; -const FILE_MAX_ENTRIES = 50_000; -// Cap GUID length so a malformed or hostile payload can't bloat the on-disk -// dedupe file. Real BB GUIDs are short (<64 chars); 512 is generous. -const MAX_GUID_CHARS = 512; - -function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): string { - if (env.VITEST || env.NODE_ENV === "test") { - // Isolate tests from real ~/.openclaw state without sharing across tests. - // Stable-per-pid so the scoped dedupe test can observe persistence. - const name = "openclaw-vitest-" + process.pid; - return path.join(resolvePreferredOpenClawTmpDir(), name); - } - // Canonical OpenClaw state dir: honors OPENCLAW_STATE_DIR (with `~` expansion - // via resolveUserPath), plus legacy/new fallback. Using the shared helper - // keeps this plugin's persistence aligned with the rest of OpenClaw state. - return resolveStateDir(env); -} - -function resolveLegacyNamespaceFilePath(namespace: string): string { - const safe = namespace.replace(/[^a-zA-Z0-9_-]/g, "_") || "global"; - return path.join(resolveStateDirFromEnv(), "bluebubbles", "inbound-dedupe", `${safe}.json`); -} - -function resolveNamespaceFilePath(namespace: string): string { - // Keep a readable prefix for operator debugging, but suffix with a short - // hash of the raw namespace so account IDs that only differ by - // filesystem-unsafe characters (e.g. "acct/a" vs "acct:a") don't collapse - // onto the same file. - const safePrefix = namespace.replace(/[^a-zA-Z0-9_-]/g, "_") || "ns"; - const hash = createHash("sha256").update(namespace, "utf8").digest("hex").slice(0, 12); - const dir = path.join(resolveStateDirFromEnv(), "bluebubbles", "inbound-dedupe"); - const newPath = path.join(dir, `${safePrefix}__${hash}.json`); - - // One-time migration: earlier beta shipped `${safe}.json` (no hash). - // Rename so the upgrade preserves existing dedupe entries instead of - // starting from an empty file and replaying already-handled messages. - migrateLegacyDedupeFile(namespace, newPath); - - return newPath; -} - -const migratedNamespaces = new Set(); - -function migrateLegacyDedupeFile(namespace: string, newPath: string): void { - if (migratedNamespaces.has(namespace)) { - return; - } - migratedNamespaces.add(namespace); - try { - const legacyPath = resolveLegacyNamespaceFilePath(namespace); - if (legacyPath === newPath) { - return; - } - if (!fs.existsSync(legacyPath)) { - return; - } - if (!fs.existsSync(newPath)) { - fs.renameSync(legacyPath, newPath); - } else { - // Both exist: new file is authoritative; remove the stale legacy. - fs.unlinkSync(legacyPath); - } - } catch { - // Best-effort migration; a missed rename is strictly less harmful - // than crashing the module load path. - } -} - -function buildPersistentImpl(): ClaimableDedupe { - return createClaimableDedupe({ - ttlMs: DEDUP_TTL_MS, - memoryMaxSize: MEMORY_MAX_SIZE, - fileMaxEntries: FILE_MAX_ENTRIES, - resolveFilePath: resolveNamespaceFilePath, - }); -} - -function buildMemoryOnlyImpl(): ClaimableDedupe { - return createClaimableDedupe({ - ttlMs: DEDUP_TTL_MS, - memoryMaxSize: MEMORY_MAX_SIZE, - }); -} - -let impl: ClaimableDedupe = buildPersistentImpl(); - -function sanitizeGuid(guid: string | undefined | null): string | null { - const trimmed = guid?.trim(); - if (!trimmed) { - return null; - } - if (trimmed.length > MAX_GUID_CHARS) { - return null; - } - return trimmed; -} - -/** - * Resolve the canonical dedupe key for a BlueBubbles inbound message. - * - * Mirrors `monitor-debounce.ts`'s `buildKey`: BlueBubbles sends URL-preview - * / sticker "balloon" events with a different `messageId` than the text - * message they belong to, and the debouncer coalesces the two only when - * both `balloonBundleId` AND `associatedMessageGuid` are present. We gate - * on the same pair so that regular replies — which also set - * `associatedMessageGuid` (pointing at the parent message) but have no - * `balloonBundleId` — are NOT collapsed onto their parent's dedupe key. - * - * Known tradeoff: `combineDebounceEntries` clears `balloonBundleId` on - * merged entries while keeping `associatedMessageGuid`, so a post-merge - * balloon+text message here will fall back to its `messageId`. A later - * MessagePoller replay that arrives in a different text-first/balloon-first - * order could therefore produce a different `messageId` at merge time and - * bypass this dedupe for that one message. That edge case is strictly - * narrower than the alternative — which would dedupe every distinct user - * reply against the same parent GUID and silently drop real messages. - */ -export function resolveBlueBubblesInboundDedupeKey( - message: Pick< - NormalizedWebhookMessage, - "messageId" | "balloonBundleId" | "associatedMessageGuid" | "eventType" - >, -): string | undefined { - const balloonBundleId = message.balloonBundleId?.trim(); - const associatedMessageGuid = message.associatedMessageGuid?.trim(); - let base: string | undefined; - if (balloonBundleId && associatedMessageGuid) { - base = associatedMessageGuid; - } else { - base = message.messageId?.trim() || undefined; - } - if (!base) { - return undefined; - } - // `updated-message` events get a distinct key so they are not rejected as - // duplicates of the already-committed `new-message` for the same GUID. - // This lets attachment-carrying follow-up webhooks through. (#65430, #52277) - if (message.eventType === "updated-message") { - return `${base}:updated`; - } - return base; -} - -type InboundDedupeClaim = - | { kind: "claimed"; finalize: () => Promise; release: () => void } - | { kind: "duplicate" } - | { kind: "inflight" } - | { kind: "skip" }; - -/** - * Attempt to claim an inbound BlueBubbles message GUID. - * - * - `claimed`: caller should process the message, then call `finalize()` on - * success (persists the GUID) or `release()` on failure (lets a later - * replay try again). - * - `duplicate`: we've already committed this GUID; caller should drop. - * - `inflight`: another claim is currently in progress; caller should drop - * rather than race. - * - `skip`: GUID was missing or invalid — caller should continue processing - * without dedup (no finalize/release needed). - */ -export async function claimBlueBubblesInboundMessage(params: { - guid: string | undefined | null; - accountId: string; - onDiskError?: (error: unknown) => void; -}): Promise { - const normalized = sanitizeGuid(params.guid); - if (!normalized) { - return { kind: "skip" }; - } - const claim = await impl.claim(normalized, { - namespace: params.accountId, - onDiskError: params.onDiskError, - }); - if (claim.kind === "duplicate") { - return { kind: "duplicate" }; - } - if (claim.kind === "inflight") { - return { kind: "inflight" }; - } - return { - kind: "claimed", - finalize: async () => { - await impl.commit(normalized, { - namespace: params.accountId, - onDiskError: params.onDiskError, - }); - }, - release: () => { - impl.release(normalized, { namespace: params.accountId }); - }, - }; -} - -/** - * Mark a set of source messageIds as already processed, without going through - * the `claim()` protocol. Intended for the coalesced-batch case: when the - * debouncer merges N webhook events into one agent turn, only the primary - * messageId reaches `claimBlueBubblesInboundMessage`. The remaining source - * messageIds must still be remembered so a later MessagePoller replay of any - * single source event is recognized as a duplicate rather than re-processed. - * - * Best-effort — disk errors on secondary commits are surfaced via - * `onDiskError` but never thrown, so a single persistence hiccup cannot block - * the caller's main finalize path. - */ -export async function commitBlueBubblesCoalescedMessageIds(params: { - messageIds: readonly string[]; - accountId: string; - onDiskError?: (error: unknown) => void; -}): Promise { - for (const raw of params.messageIds) { - const normalized = sanitizeGuid(raw); - if (!normalized) { - continue; - } - await impl.commit(normalized, { - namespace: params.accountId, - onDiskError: params.onDiskError, - }); - } -} - -/** - * Ensure the legacy→hashed dedupe file migration runs and the on-disk - * store is warmed into memory for the given account. Call before any - * catchup replay so already-handled GUIDs are recognized even when the - * file-naming convention changed between versions. - */ -export async function warmupBlueBubblesInboundDedupe(accountId: string): Promise { - // Trigger the migration side-effect inside resolveNamespaceFilePath. - resolveNamespaceFilePath(accountId); - await impl.warmup(accountId); -} - -/** - * Reset inbound dedupe state between tests. Installs an in-memory-only - * implementation so tests do not hit disk, avoiding file-lock timing issues - * in the webhook flush path. - */ -export function _resetBlueBubblesInboundDedupForTest(): void { - impl = buildMemoryOnlyImpl(); -} diff --git a/extensions/bluebubbles/src/media-send.test.ts b/extensions/bluebubbles/src/media-send.test.ts deleted file mode 100644 index a1b0d59c30a0..000000000000 --- a/extensions/bluebubbles/src/media-send.test.ts +++ /dev/null @@ -1,356 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; -import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { sendBlueBubblesMedia } from "./media-send.js"; -import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js"; -import { setBlueBubblesRuntime } from "./runtime.js"; - -const sendBlueBubblesAttachmentMock = vi.hoisted(() => vi.fn()); -const sendMessageBlueBubblesMock = vi.hoisted(() => vi.fn()); -const resolveBlueBubblesMessageIdMock = vi.hoisted(() => vi.fn((id: string) => id)); - -vi.mock("./attachments.js", () => ({ - sendBlueBubblesAttachment: sendBlueBubblesAttachmentMock, -})); - -vi.mock("./send.js", () => ({ - sendMessageBlueBubbles: sendMessageBlueBubblesMock, -})); - -vi.mock("./monitor-reply-cache.js", () => ({ - resolveBlueBubblesMessageId: resolveBlueBubblesMessageIdMock, -})); - -afterAll(() => { - vi.doUnmock("./attachments.js"); - vi.doUnmock("./send.js"); - vi.doUnmock("./monitor-reply-cache.js"); - vi.resetModules(); -}); - -type RuntimeMocks = { - detectMime: ReturnType; - fetchRemoteMedia: ReturnType; -}; - -let runtimeMocks: RuntimeMocks; -const tempDirs: string[] = []; - -function createMockRuntime(): { runtime: PluginRuntime; mocks: RuntimeMocks } { - const detectMime = vi.fn().mockResolvedValue("text/plain"); - const fetchRemoteMedia = vi.fn().mockResolvedValue({ - buffer: new Uint8Array([1, 2, 3]), - contentType: "image/png", - fileName: "remote.png", - }); - return { - runtime: { - version: "1.0.0", - media: { - detectMime, - }, - channel: { - media: { - fetchRemoteMedia, - }, - }, - } as unknown as PluginRuntime, - mocks: { detectMime, fetchRemoteMedia }, - }; -} - -function createConfig(overrides?: Record): OpenClawConfig { - return { - channels: { - bluebubbles: { - ...overrides, - }, - }, - } as unknown as OpenClawConfig; -} - -async function makeTempDir(): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-bb-media-")); - tempDirs.push(dir); - return dir; -} - -async function makeTempFile( - fileName: string, - contents: string, - dir?: string, -): Promise<{ dir: string; filePath: string }> { - const resolvedDir = dir ?? (await makeTempDir()); - const filePath = path.join(resolvedDir, fileName); - await fs.writeFile(filePath, contents, "utf8"); - return { dir: resolvedDir, filePath }; -} - -async function sendLocalMedia(params: { - cfg: OpenClawConfig; - mediaPath: string; - accountId?: string; -}) { - return sendBlueBubblesMedia({ - cfg: params.cfg, - to: "chat:123", - accountId: params.accountId, - mediaPath: params.mediaPath, - }); -} - -async function expectRejectedLocalMedia(params: { - cfg: OpenClawConfig; - mediaPath: string; - error: RegExp; - accountId?: string; -}) { - await expect( - sendLocalMedia({ - cfg: params.cfg, - mediaPath: params.mediaPath, - accountId: params.accountId, - }), - ).rejects.toThrow(params.error); - - expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled(); -} - -async function expectAllowedLocalMedia(params: { - cfg: OpenClawConfig; - mediaPath: string; - expectedAttachment: Record; - accountId?: string; - expectMimeDetection?: boolean; -}) { - const result = await sendLocalMedia({ - cfg: params.cfg, - mediaPath: params.mediaPath, - accountId: params.accountId, - }); - - expect(result).toEqual({ messageId: "msg-1" }); - expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1); - expect(sendBlueBubblesAttachmentMock.mock.calls[0]?.[0]).toEqual( - expect.objectContaining(params.expectedAttachment), - ); - if (params.expectMimeDetection) { - expect(runtimeMocks.detectMime).toHaveBeenCalled(); - } -} - -beforeEach(() => { - const runtime = createMockRuntime(); - runtimeMocks = runtime.mocks; - setBlueBubblesRuntime(runtime.runtime); - sendBlueBubblesAttachmentMock.mockReset(); - sendBlueBubblesAttachmentMock.mockResolvedValue({ messageId: "msg-1" }); - sendMessageBlueBubblesMock.mockReset(); - sendMessageBlueBubblesMock.mockResolvedValue({ messageId: "msg-caption" }); - resolveBlueBubblesMessageIdMock.mockClear(); -}); - -afterEach(async () => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop(); - if (!dir) { - continue; - } - await fs.rm(dir, { recursive: true, force: true }); - } -}); - -describe("sendBlueBubblesMedia local-path hardening", () => { - it("rejects local paths when mediaLocalRoots is not configured", async () => { - await expect( - sendBlueBubblesMedia({ - cfg: createConfig(), - to: "chat:123", - mediaPath: "/etc/passwd", - }), - ).rejects.toThrow(/mediaLocalRoots/i); - - expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled(); - }); - - it("rejects local paths outside configured mediaLocalRoots", async () => { - const allowedRoot = await makeTempDir(); - const outsideDir = await makeTempDir(); - const outsideFile = path.join(outsideDir, "outside.txt"); - await fs.writeFile(outsideFile, "not allowed", "utf8"); - - await expectRejectedLocalMedia({ - cfg: createConfig({ mediaLocalRoots: [allowedRoot] }), - mediaPath: outsideFile, - error: /not under any configured mediaLocalRoots/i, - }); - }); - - it("allows local paths that are explicitly configured", async () => { - const { dir: allowedRoot, filePath: allowedFile } = await makeTempFile( - "allowed.txt", - "allowed", - ); - - await expectAllowedLocalMedia({ - cfg: createConfig({ mediaLocalRoots: [allowedRoot] }), - mediaPath: allowedFile, - expectedAttachment: { - filename: "allowed.txt", - contentType: "text/plain", - }, - expectMimeDetection: true, - }); - }); - - it("allows file:// media paths and file:// local roots", async () => { - const { dir: allowedRoot, filePath: allowedFile } = await makeTempFile( - "allowed.txt", - "allowed", - ); - - await expectAllowedLocalMedia({ - cfg: createConfig({ mediaLocalRoots: [pathToFileURL(allowedRoot).toString()] }), - mediaPath: pathToFileURL(allowedFile).toString(), - expectedAttachment: { - filename: "allowed.txt", - }, - }); - }); - - it("rejects remote-host file:// media paths", async () => { - const allowedRoot = await makeTempDir(); - - await expectRejectedLocalMedia({ - cfg: createConfig({ mediaLocalRoots: [allowedRoot] }), - mediaPath: "file://attacker/share/evil.txt", - error: /Invalid file:\/\/ URL/i, - }); - }); - - it("rejects remote-host file:// mediaLocalRoots entries", async () => { - const { filePath: allowedFile } = await makeTempFile("allowed.txt", "allowed"); - - await expect( - sendBlueBubblesMedia({ - cfg: createConfig({ mediaLocalRoots: ["file://attacker/share"] }), - to: "chat:123", - mediaPath: allowedFile, - }), - ).rejects.toThrow(/Invalid file:\/\/ URL in mediaLocalRoots/i); - - expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled(); - }); - - it("uses account-specific mediaLocalRoots over top-level roots", async () => { - const baseRoot = await makeTempDir(); - const accountRoot = await makeTempDir(); - const baseFile = path.join(baseRoot, "base.txt"); - const accountFile = path.join(accountRoot, "account.txt"); - await fs.writeFile(baseFile, "base", "utf8"); - await fs.writeFile(accountFile, "account", "utf8"); - - const cfg = createConfig({ - mediaLocalRoots: [baseRoot], - accounts: { - work: { - mediaLocalRoots: [accountRoot], - }, - }, - }); - - await expect( - sendBlueBubblesMedia({ - cfg, - to: "chat:123", - accountId: "work", - mediaPath: baseFile, - }), - ).rejects.toThrow(/not under any configured mediaLocalRoots/i); - - const result = await sendBlueBubblesMedia({ - cfg, - to: "chat:123", - accountId: "work", - mediaPath: accountFile, - }); - - expect(result).toEqual({ messageId: "msg-1" }); - }); - - it("rejects symlink escapes under an allowed root", async () => { - const allowedRoot = await makeTempDir(); - const outsideDir = await makeTempDir(); - const outsideFile = path.join(outsideDir, "secret.txt"); - const linkPath = path.join(allowedRoot, "link.txt"); - await fs.writeFile(outsideFile, "secret", "utf8"); - - try { - await fs.symlink(outsideFile, linkPath); - } catch { - // Some environments disallow symlink creation; skip without failing the suite. - return; - } - - await expectRejectedLocalMedia({ - cfg: createConfig({ mediaLocalRoots: [allowedRoot] }), - mediaPath: linkPath, - error: /not under any configured mediaLocalRoots/i, - }); - }); - - it("rejects relative mediaLocalRoots entries", async () => { - const allowedRoot = await makeTempDir(); - const allowedFile = path.join(allowedRoot, "allowed.txt"); - const relativeRoot = path.relative(process.cwd(), allowedRoot); - await fs.writeFile(allowedFile, "allowed", "utf8"); - - await expect( - sendBlueBubblesMedia({ - cfg: createConfig({ mediaLocalRoots: [relativeRoot] }), - to: "chat:123", - mediaPath: allowedFile, - }), - ).rejects.toThrow(/must be absolute paths/i); - - expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled(); - }); - - it("keeps remote URL flow unchanged", async () => { - await sendBlueBubblesMedia({ - cfg: createConfig(), - to: "chat:123", - mediaUrl: "https://example.com/file.png", - }); - - expect(runtimeMocks.fetchRemoteMedia).toHaveBeenCalledWith( - expect.objectContaining({ url: "https://example.com/file.png" }), - ); - expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1); - }); - - it("passes asVoice through to attachment delivery", async () => { - runtimeMocks.fetchRemoteMedia.mockResolvedValueOnce({ - buffer: new Uint8Array([1, 2, 3]), - contentType: "audio/mpeg", - fileName: "voice.mp3", - }); - - await sendBlueBubblesMedia({ - cfg: createConfig(), - to: "chat:123", - mediaUrl: "https://example.com/voice.mp3", - asVoice: true, - }); - - expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledWith( - expect.objectContaining({ - asVoice: true, - contentType: "audio/mpeg", - filename: "voice.mp3", - }), - ); - }); -}); diff --git a/extensions/bluebubbles/src/media-send.ts b/extensions/bluebubbles/src/media-send.ts deleted file mode 100644 index d1b04c6b1c02..000000000000 --- a/extensions/bluebubbles/src/media-send.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { - basenameFromMediaSource, - readLocalFileFromRoots, -} from "openclaw/plugin-sdk/file-access-runtime"; -import { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime"; -import { resolveBlueBubblesAccount } from "./accounts.js"; -import { sendBlueBubblesAttachment } from "./attachments.js"; -import { resolveBlueBubblesMessageId } from "./monitor-reply-cache.js"; -import type { OpenClawConfig } from "./runtime-api.js"; -import { getBlueBubblesRuntime } from "./runtime.js"; -import { sendMessageBlueBubbles } from "./send.js"; -import { buildBlueBubblesChatContextFromTarget } from "./targets.js"; - -const HTTP_URL_RE = /^https?:\/\//i; -const MB = 1024 * 1024; - -function assertMediaWithinLimit(sizeBytes: number, maxBytes?: number): void { - if (typeof maxBytes !== "number" || maxBytes <= 0) { - return; - } - if (sizeBytes <= maxBytes) { - return; - } - const maxLabel = (maxBytes / MB).toFixed(0); - const sizeLabel = (sizeBytes / MB).toFixed(2); - throw new Error(`Media exceeds ${maxLabel}MB limit (got ${sizeLabel}MB)`); -} - -function resolveMediaLocalRoots(params: { cfg: OpenClawConfig; accountId?: string }): string[] { - const account = resolveBlueBubblesAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - return (account.config.mediaLocalRoots ?? []) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); -} - -async function assertLocalMediaPathAllowed(params: { - localPath: string; - localRoots: string[]; - accountId?: string; -}): Promise<{ data: Buffer; realPath: string; sizeBytes: number }> { - if (params.localRoots.length === 0) { - throw new Error( - `Local BlueBubbles media paths are disabled by default. Set channels.bluebubbles.mediaLocalRoots${ - params.accountId - ? ` or channels.bluebubbles.accounts.${params.accountId}.mediaLocalRoots` - : "" - } to explicitly allow local file directories.`, - ); - } - - const localFile = await readLocalFileFromRoots({ - filePath: params.localPath, - roots: params.localRoots, - label: "mediaLocalRoots", - }); - if (localFile) { - return { - data: localFile.buffer, - realPath: localFile.realPath, - sizeBytes: localFile.stat.size, - }; - } - - throw new Error( - `Local media path is not under any configured mediaLocalRoots entry: ${params.localPath}`, - ); -} - -function resolveFilenameFromSource(source?: string): string | undefined { - return basenameFromMediaSource(source); -} - -export async function sendBlueBubblesMedia(params: { - cfg: OpenClawConfig; - to: string; - mediaUrl?: string; - mediaPath?: string; - mediaBuffer?: Uint8Array; - contentType?: string; - filename?: string; - caption?: string; - replyToId?: string | null; - accountId?: string; - asVoice?: boolean; -}) { - const { - cfg, - to, - mediaUrl, - mediaPath, - mediaBuffer, - contentType, - filename, - caption, - replyToId, - accountId, - asVoice, - } = params; - const core = getBlueBubblesRuntime(); - const maxBytes = resolveChannelMediaMaxBytes({ - cfg, - resolveChannelLimitMb: ({ cfg, accountId }) => - (cfg.channels?.bluebubbles?.accounts?.[accountId] as { mediaMaxMb?: number } | undefined) - ?.mediaMaxMb ?? cfg.channels?.bluebubbles?.mediaMaxMb, - accountId, - }); - const mediaLocalRoots = resolveMediaLocalRoots({ cfg, accountId }); - - let buffer: Uint8Array; - let resolvedContentType = contentType ?? undefined; - let resolvedFilename = filename ?? undefined; - - if (mediaBuffer) { - assertMediaWithinLimit(mediaBuffer.byteLength, maxBytes); - buffer = mediaBuffer; - if (!resolvedContentType) { - const hint = mediaPath ?? mediaUrl; - const detected = await core.media.detectMime({ - buffer: Buffer.isBuffer(mediaBuffer) ? mediaBuffer : Buffer.from(mediaBuffer), - filePath: hint, - }); - resolvedContentType = detected ?? undefined; - } - if (!resolvedFilename) { - resolvedFilename = resolveFilenameFromSource(mediaPath ?? mediaUrl); - } - } else { - const source = mediaPath ?? mediaUrl; - if (!source) { - throw new Error("BlueBubbles media delivery requires mediaUrl, mediaPath, or mediaBuffer."); - } - if (HTTP_URL_RE.test(source)) { - const fetched = await core.channel.media.fetchRemoteMedia({ - url: source, - maxBytes: typeof maxBytes === "number" && maxBytes > 0 ? maxBytes : undefined, - }); - buffer = fetched.buffer; - resolvedContentType = resolvedContentType ?? fetched.contentType ?? undefined; - resolvedFilename = resolvedFilename ?? fetched.fileName; - } else { - const localFile = await assertLocalMediaPathAllowed({ - localPath: source, - localRoots: mediaLocalRoots, - accountId, - }); - if (typeof maxBytes === "number" && maxBytes > 0) { - assertMediaWithinLimit(localFile.sizeBytes, maxBytes); - } - const data = localFile.data; - assertMediaWithinLimit(data.byteLength, maxBytes); - buffer = new Uint8Array(data); - if (!resolvedContentType) { - const detected = await core.media.detectMime({ - buffer: data, - filePath: localFile.realPath, - }); - resolvedContentType = detected ?? undefined; - } - if (!resolvedFilename) { - resolvedFilename = resolveFilenameFromSource(localFile.realPath); - } - } - } - - // Resolve short ID (e.g., "5") to full UUID, scoped to `to` so a short ID - // tied to a message in a different chat cannot silently redirect the media - // reply into the wrong conversation (cross-chat guard). - const replyToMessageGuid = replyToId?.trim() - ? resolveBlueBubblesMessageId(replyToId.trim(), { - requireKnownShortId: true, - chatContext: buildBlueBubblesChatContextFromTarget(to), - }) - : undefined; - - const attachmentResult = await sendBlueBubblesAttachment({ - to, - buffer, - filename: resolvedFilename ?? "attachment", - contentType: resolvedContentType ?? undefined, - replyToMessageGuid, - asVoice, - opts: { - cfg, - accountId, - }, - }); - - const trimmedCaption = caption?.trim(); - if (trimmedCaption) { - await sendMessageBlueBubbles(to, trimmedCaption, { - cfg, - accountId, - replyToMessageGuid, - }); - } - - return attachmentResult; -} diff --git a/extensions/bluebubbles/src/monitor-debounce.ts b/extensions/bluebubbles/src/monitor-debounce.ts deleted file mode 100644 index 5ea9cf73bdb7..000000000000 --- a/extensions/bluebubbles/src/monitor-debounce.ts +++ /dev/null @@ -1,337 +0,0 @@ -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; -import type { NormalizedWebhookMessage } from "./monitor-normalize.js"; -import type { BlueBubblesCoreRuntime, WebhookTarget } from "./monitor-shared.js"; -import type { OpenClawConfig } from "./runtime-api.js"; - -/** - * Entry type for debouncing inbound messages. - * Captures the normalized message and its target for later combined processing. - */ -type BlueBubblesDebounceEntry = { - message: NormalizedWebhookMessage; - target: WebhookTarget; -}; - -function normalizeDebounceMessageText(text: unknown): string { - return typeof text === "string" ? text : ""; -} - -function sanitizeDebounceEntry(entry: BlueBubblesDebounceEntry): BlueBubblesDebounceEntry { - if (typeof entry.message.text === "string") { - return entry; - } - return { - ...entry, - message: { - ...entry.message, - text: "", - }, - }; -} - -type BlueBubblesDebouncer = { - enqueue: (item: BlueBubblesDebounceEntry) => Promise; - flushKey: (key: string) => Promise; -}; - -type BlueBubblesDebounceRegistry = { - getOrCreateDebouncer: (target: WebhookTarget) => BlueBubblesDebouncer; - removeDebouncer: (target: WebhookTarget) => void; -}; - -/** - * Default debounce window for inbound message coalescing (ms). - * This helps combine URL text + link preview balloon messages that BlueBubbles - * sends as separate webhook events when no explicit inbound debounce config exists. - */ -const DEFAULT_INBOUND_DEBOUNCE_MS = 500; - -/** - * Default debounce window when `coalesceSameSenderDms` is enabled. - * - * The legacy 500 ms default is tuned for BlueBubbles's own text+balloon - * pairing, which is typically linked by `associatedMessageGuid` and arrives - * within ~100-300 ms. The new split-send case this flag targets has a wider - * cadence — live traces show Apple delivers `Dump` and its pasted-URL - * balloon ~0.8-2.0 s apart — so 500 ms would flush the text alone before the - * balloon webhook ever reaches the debouncer. 2500 ms comfortably covers the - * observed range while keeping agent-reply latency acceptable for DMs. Users - * who want tighter turnaround can still set `messages.inbound.byChannel.bluebubbles` - * explicitly. - */ -const DEFAULT_COALESCE_INBOUND_DEBOUNCE_MS = 2500; - -/** - * Bounds on the combined output when multiple inbound events are merged into - * one agent turn. Guards against amplification from a sender who rapid-fires - * many small DMs inside the debounce window (concern raised on #69258): the - * merged text, attachment list, and source-message count are each capped so - * a flood cannot balloon a single agent prompt beyond a safe ceiling. - * Callers still see every messageId via inbound-dedupe. - */ -const MAX_COALESCED_TEXT_CHARS = 4000; -const MAX_COALESCED_ATTACHMENTS = 20; -const MAX_COALESCED_ENTRIES = 10; - -/** - * Combines multiple debounced messages into a single message for processing. - * Used when multiple webhook events arrive within the debounce window. - */ -function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): NormalizedWebhookMessage { - if (entries.length === 0) { - throw new Error("Cannot combine empty entries"); - } - if (entries.length === 1) { - return entries[0].message; - } - - // Use the first message as the base (typically the text message) - const first = entries[0].message; - - // Cap the number of source entries we fold into the merged view so a sender - // who rapid-fires many small DMs cannot amplify the downstream prompt. - // Prefer the first and the most recent — the first preserves the original - // command/context and the last preserves the most recent payload — rather - // than dropping either tail of the sequence. - const boundedEntries = - entries.length > MAX_COALESCED_ENTRIES - ? [...entries.slice(0, MAX_COALESCED_ENTRIES - 1), entries[entries.length - 1]] - : entries; - - // Combine text from bounded entries, filtering out duplicates and empty strings - const seenTexts = new Set(); - const textParts: string[] = []; - - for (const entry of boundedEntries) { - const text = normalizeDebounceMessageText(entry.message.text).trim(); - if (!text) { - continue; - } - // Skip duplicate text (URL might be in both text message and balloon) - const normalizedText = normalizeLowercaseStringOrEmpty(text); - if (seenTexts.has(normalizedText)) { - continue; - } - seenTexts.add(normalizedText); - textParts.push(text); - } - - let combinedText = textParts.join(" "); - if (combinedText.length > MAX_COALESCED_TEXT_CHARS) { - combinedText = `${combinedText.slice(0, MAX_COALESCED_TEXT_CHARS)}…[truncated]`; - } - - // Merge attachments from bounded entries, capped to keep downstream media - // fan-out proportional to what a single message would carry. - const allAttachments = boundedEntries - .flatMap((e) => e.message.attachments ?? []) - .slice(0, MAX_COALESCED_ATTACHMENTS); - - // Use the latest timestamp - const timestamps = entries - .map((e) => e.message.timestamp) - .filter((t): t is number => typeof t === "number"); - const latestTimestamp = timestamps.length > 0 ? Math.max(...timestamps) : first.timestamp; - - // Collect all message IDs for reference - const messageId = entries.map((e) => e.message.messageId).find((id): id is string => Boolean(id)); - - // Every source messageId we're folding into this merged view must reach - // inbound-dedupe, so a later BlueBubbles MessagePoller replay of any single - // source event is recognized as a duplicate rather than re-processed as a - // fresh agent turn. We walk the unbounded `entries` (not `boundedEntries`) - // so even IDs whose text/attachments were dropped by the cap are still - // remembered. - const seenIds = new Set(); - const coalescedMessageIds: string[] = []; - for (const entry of entries) { - const id = entry.message.messageId?.trim(); - if (!id || seenIds.has(id)) { - continue; - } - seenIds.add(id); - coalescedMessageIds.push(id); - } - - // Prefer reply context from any entry that has it - const entryWithReply = entries.find((e) => e.message.replyToId); - - return { - ...first, - text: combinedText, - attachments: allAttachments.length > 0 ? allAttachments : first.attachments, - timestamp: latestTimestamp, - // Use first message's ID as primary (for reply reference), but we've coalesced others - messageId: messageId ?? first.messageId, - coalescedMessageIds: coalescedMessageIds.length > 0 ? coalescedMessageIds : undefined, - // Preserve reply context if present - replyToId: entryWithReply?.message.replyToId ?? first.replyToId, - replyToBody: entryWithReply?.message.replyToBody ?? first.replyToBody, - replyToSender: entryWithReply?.message.replyToSender ?? first.replyToSender, - // Clear balloonBundleId since we've combined (the combined message is no longer just a balloon) - balloonBundleId: undefined, - }; -} - -function resolveBlueBubblesDebounceMs( - config: OpenClawConfig, - core: BlueBubblesCoreRuntime, - accountConfig: { coalesceSameSenderDms?: boolean }, -): number { - const inbound = config.messages?.inbound; - const hasExplicitDebounce = - typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number"; - if (!hasExplicitDebounce) { - // When the opt-in coalesce flag is on, the default must cover Apple's - // split-send cadence (~0.8-2.0 s) or the flag becomes a no-op. Other - // users keep the legacy tight default tuned for text+balloon pairs - // linked via `associatedMessageGuid`. - return accountConfig.coalesceSameSenderDms - ? DEFAULT_COALESCE_INBOUND_DEBOUNCE_MS - : DEFAULT_INBOUND_DEBOUNCE_MS; - } - // Explicit config path: delegate to the shared runtime helper so per- - // channel scaling, clamps, or other future logic in - // `src/auto-reply/inbound-debounce.ts` stay authoritative for every - // channel uniformly. - return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" }); -} - -export function createBlueBubblesDebounceRegistry(params: { - processMessage: (message: NormalizedWebhookMessage, target: WebhookTarget) => Promise; -}): BlueBubblesDebounceRegistry { - const targetDebouncers = new Map(); - - return { - getOrCreateDebouncer: (target) => { - const existing = targetDebouncers.get(target); - if (existing) { - return existing; - } - - const { account, config, runtime, core } = target; - const baseDebouncer = core.channel.debounce.createInboundDebouncer({ - debounceMs: resolveBlueBubblesDebounceMs(config, core, account.config), - buildKey: (entry) => { - const msg = entry.message; - // Prefer stable, shared identifiers to coalesce rapid-fire webhook events for the - // same message (e.g., text-only then text+attachment). - // - // For balloons (URL previews, stickers, etc), BlueBubbles often uses a different - // messageId than the originating text. When present, key by associatedMessageGuid - // to keep text + balloon coalescing working. - const balloonBundleId = msg.balloonBundleId?.trim(); - const associatedMessageGuid = msg.associatedMessageGuid?.trim(); - if (balloonBundleId && associatedMessageGuid) { - return `bluebubbles:${account.accountId}:msg:${associatedMessageGuid}`; - } - - // Optional: coalesce consecutive DM messages from the same sender - // within the debounce window. Two distinct user sends (e.g. - // `Dump` followed by a pasted URL that iMessage renders as a - // standalone rich-link balloon) have distinct messageIds and no - // associatedMessageGuid cross-reference, so the default per-message - // key dispatches them as separate agent turns. Hashing to - // chat:sender lets the debounce window merge them. DMs only — - // group chats continue to key per-message to preserve multi-user - // conversational structure. - // - // We intentionally do NOT guard on `!balloonBundleId` here: an - // orphan URL-balloon (Apple split-send where the balloon event - // carries `balloonBundleId` but no `associatedMessageGuid` linking - // it back to the text) is exactly the traffic this feature - // targets. The legacy text+balloon pairing case is already - // captured above by the `balloonBundleId && associatedMessageGuid` - // branch, so skipping balloons here would defeat the opt-in for - // its primary motivating case. - const chatKey = - msg.chatGuid?.trim() ?? - msg.chatIdentifier?.trim() ?? - (msg.chatId ? String(msg.chatId) : "dm"); - if (account.config.coalesceSameSenderDms && !msg.isGroup && !associatedMessageGuid) { - return `bluebubbles:${account.accountId}:dm:${chatKey}:${msg.senderId}`; - } - - const messageId = msg.messageId?.trim(); - if (messageId) { - return `bluebubbles:${account.accountId}:msg:${messageId}`; - } - - return `bluebubbles:${account.accountId}:${chatKey}:${msg.senderId}`; - }, - shouldDebounce: (entry) => { - const msg = entry.message; - // Skip debouncing for from-me messages (they're just cached, not processed) - if (msg.fromMe) { - return false; - } - // Control commands normally flush immediately so the command feels - // instant. Exception: when `coalesceSameSenderDms` is enabled, a DM - // control command is frequently the first half of a split-send - // (e.g. `Dump` followed by a pasted URL that Apple delivers as a - // separate webhook ~700-2000 ms later). Skipping debounce here - // would flush the command alone before the URL bucket-mate arrives - // — defeating the opt-in feature on exactly its target traffic. - // Gate the delay on the same conditions as the buildKey coalesce - // branch so group chats, balloon follow-ups, and disabled accounts - // keep the instant-flush path. - if (core.channel.text.hasControlCommand(msg.text, config)) { - const associatedMessageGuid = msg.associatedMessageGuid?.trim(); - if (account.config.coalesceSameSenderDms && !msg.isGroup && !associatedMessageGuid) { - return true; - } - return false; - } - // Debounce all other messages to coalesce rapid-fire webhook events - // (e.g., text+image arriving as separate webhooks for the same messageId) - return true; - }, - onFlush: async (entries) => { - if (entries.length === 0) { - return; - } - - // Use target from first entry (all entries have same target due to key structure) - const flushTarget = entries[0].target; - - if (entries.length === 1) { - // Single message - process normally - await params.processMessage(entries[0].message, flushTarget); - return; - } - - // Multiple messages - combine and process - const combined = combineDebounceEntries(entries); - - if (core.logging.shouldLogVerbose()) { - const count = entries.length; - const preview = combined.text.slice(0, 50); - runtime.log?.( - `[bluebubbles] coalesced ${count} messages: "${preview}${combined.text.length > 50 ? "..." : ""}"`, - ); - } - - await params.processMessage(combined, flushTarget); - }, - onError: (err) => { - runtime.error?.( - `[${account.accountId}] [bluebubbles] debounce flush failed: ${String(err)}`, - ); - }, - }); - - const debouncer: BlueBubblesDebouncer = { - enqueue: async (item) => { - await baseDebouncer.enqueue(sanitizeDebounceEntry(item)); - }, - flushKey: (key) => baseDebouncer.flushKey(key), - }; - - targetDebouncers.set(target, debouncer); - return debouncer; - }, - removeDebouncer: (target) => { - targetDebouncers.delete(target); - }, - }; -} diff --git a/extensions/bluebubbles/src/monitor-normalize.test.ts b/extensions/bluebubbles/src/monitor-normalize.test.ts deleted file mode 100644 index 10f66b0e8f71..000000000000 --- a/extensions/bluebubbles/src/monitor-normalize.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildMessagePlaceholder, - isBlueBubblesAudioAttachment, - normalizeWebhookMessage, - normalizeWebhookReaction, -} from "./monitor-normalize.js"; - -function createFallbackDmPayload(overrides: Record = {}) { - return { - guid: "msg-1", - isGroup: false, - isFromMe: false, - handle: null, - chatGuid: "iMessage;-;+15551234567", - ...overrides, - }; -} - -describe("normalizeWebhookMessage", () => { - it("falls back to DM chatGuid handle when sender handle is missing", () => { - const result = normalizeWebhookMessage({ - type: "new-message", - data: createFallbackDmPayload({ - text: "hello", - }), - }); - - expect(result).not.toBeNull(); - expect(result?.senderId).toBe("+15551234567"); - expect(result?.senderIdExplicit).toBe(false); - expect(result?.chatGuid).toBe("iMessage;-;+15551234567"); - }); - - it("marks explicit sender handles as explicit identity", () => { - const result = normalizeWebhookMessage({ - type: "new-message", - data: { - guid: "msg-explicit-1", - text: "hello", - isGroup: false, - isFromMe: true, - handle: { address: "+15551234567" }, - chatGuid: "iMessage;-;+15551234567", - }, - }); - - expect(result).not.toBeNull(); - expect(result?.senderId).toBe("+15551234567"); - expect(result?.senderIdExplicit).toBe(true); - }); - - it("does not infer sender from group chatGuid when sender handle is missing", () => { - const result = normalizeWebhookMessage({ - type: "new-message", - data: { - guid: "msg-1", - text: "hello group", - isGroup: true, - isFromMe: false, - handle: null, - chatGuid: "iMessage;+;chat123456", - }, - }); - - expect(result).toBeNull(); - }); - - it("accepts array-wrapped payload data", () => { - const result = normalizeWebhookMessage({ - type: "new-message", - data: [ - { - guid: "msg-1", - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - }, - ], - }); - - expect(result).not.toBeNull(); - expect(result?.senderId).toBe("+15551234567"); - }); - - it("normalizes participant handles from the handles field", () => { - const result = normalizeWebhookMessage({ - type: "new-message", - data: { - guid: "msg-handles-1", - text: "hello group", - isGroup: true, - isFromMe: false, - handle: { address: "+15550000000" }, - chatGuid: "iMessage;+;chat123456", - handles: [ - { address: "+15551234567", displayName: "Alice" }, - { address: "+15557654321", displayName: "Bob" }, - ], - }, - }); - - expect(result).not.toBeNull(); - expect(result?.participants).toEqual([ - { id: "+15551234567", name: "Alice" }, - { id: "+15557654321", name: "Bob" }, - ]); - }); - - it("normalizes participant handles from the participantHandles field", () => { - const result = normalizeWebhookMessage({ - type: "new-message", - data: { - guid: "msg-participant-handles-1", - text: "hello group", - isGroup: true, - isFromMe: false, - handle: { address: "+15550000000" }, - chatGuid: "iMessage;+;chat123456", - participantHandles: [{ address: "+15551234567" }, "+15557654321"], - }, - }); - - expect(result).not.toBeNull(); - expect(result?.participants).toEqual([{ id: "+15551234567" }, { id: "+15557654321" }]); - }); -}); - -describe("normalizeWebhookReaction", () => { - it("falls back to DM chatGuid handle when reaction sender handle is missing", () => { - const result = normalizeWebhookReaction({ - type: "updated-message", - data: createFallbackDmPayload({ - guid: "msg-2", - associatedMessageGuid: "p:0/msg-1", - associatedMessageType: 2000, - }), - }); - - expect(result).not.toBeNull(); - expect(result?.senderId).toBe("+15551234567"); - expect(result?.senderIdExplicit).toBe(false); - expect(result?.messageId).toBe("p:0/msg-1"); - expect(result?.action).toBe("added"); - }); -}); - -describe("isBlueBubblesAudioAttachment", () => { - it("detects audio by `audio/*` MIME type", () => { - expect(isBlueBubblesAudioAttachment({ mimeType: "audio/x-m4a" })).toBe(true); - expect(isBlueBubblesAudioAttachment({ mimeType: "audio/mp4" })).toBe(true); - }); - - it("detects audio by Apple UTI even when MIME is missing", () => { - expect(isBlueBubblesAudioAttachment({ uti: "public.audio" })).toBe(true); - expect(isBlueBubblesAudioAttachment({ uti: "public.mpeg-4-audio" })).toBe(true); - expect(isBlueBubblesAudioAttachment({ uti: "com.apple.m4a-audio" })).toBe(true); - expect(isBlueBubblesAudioAttachment({ uti: "com.apple.coreaudio-format" })).toBe(true); - }); - - it("treats UTI matching as case-insensitive", () => { - expect(isBlueBubblesAudioAttachment({ uti: "Public.Audio" })).toBe(true); - }); - - it("returns false for image / video / unknown attachments", () => { - expect(isBlueBubblesAudioAttachment({ mimeType: "image/jpeg" })).toBe(false); - expect(isBlueBubblesAudioAttachment({ mimeType: "video/quicktime" })).toBe(false); - expect(isBlueBubblesAudioAttachment({ uti: "public.jpeg" })).toBe(false); - expect(isBlueBubblesAudioAttachment({})).toBe(false); - }); -}); - -describe("buildMessagePlaceholder audio detection", () => { - function makeMsg(attachments: Array<{ mimeType?: string; uti?: string }>) { - return { - text: "", - senderId: "+15551234567", - senderIdExplicit: false, - isGroup: false, - attachments, - } as Parameters[0]; - } - - it("emits for `audio/*` MIME (existing behavior)", () => { - expect(buildMessagePlaceholder(makeMsg([{ mimeType: "audio/x-m4a" }]))).toContain( - "", - ); - }); - - it("emits for Apple `public.audio` UTI when MIME is missing", () => { - expect(buildMessagePlaceholder(makeMsg([{ uti: "public.audio" }]))).toContain(""); - }); - - it("emits for Apple `com.apple.m4a-audio` UTI", () => { - expect(buildMessagePlaceholder(makeMsg([{ uti: "com.apple.m4a-audio" }]))).toContain( - "", - ); - }); - - it("falls back to for non-audio mixes", () => { - expect( - buildMessagePlaceholder(makeMsg([{ uti: "public.audio" }, { mimeType: "image/jpeg" }])), - ).toContain(""); - }); -}); diff --git a/extensions/bluebubbles/src/monitor-normalize.ts b/extensions/bluebubbles/src/monitor-normalize.ts deleted file mode 100644 index 73346c6b0923..000000000000 --- a/extensions/bluebubbles/src/monitor-normalize.ts +++ /dev/null @@ -1,884 +0,0 @@ -import { parseFiniteNumber } from "openclaw/plugin-sdk/number-runtime"; -import { - asNullableRecord, - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, - readStringField, -} from "openclaw/plugin-sdk/text-runtime"; -import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js"; -import type { BlueBubblesAttachment } from "./types.js"; - -export const asRecord = asNullableRecord; -const readString = readStringField; - -function readNumber(record: Record | null, key: string): number | undefined { - if (!record) { - return undefined; - } - const value = record[key]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function readBoolean(record: Record | null, key: string): boolean | undefined { - if (!record) { - return undefined; - } - const value = record[key]; - return typeof value === "boolean" ? value : undefined; -} - -function readNumberLike(record: Record | null, key: string): number | undefined { - if (!record) { - return undefined; - } - return parseFiniteNumber(record[key]); -} - -export function extractAttachments(message: Record): BlueBubblesAttachment[] { - const raw = message["attachments"]; - if (!Array.isArray(raw)) { - return []; - } - const out: BlueBubblesAttachment[] = []; - for (const entry of raw) { - const record = asRecord(entry); - if (!record) { - continue; - } - out.push({ - guid: readString(record, "guid"), - uti: readString(record, "uti"), - mimeType: readString(record, "mimeType") ?? readString(record, "mime_type"), - transferName: readString(record, "transferName") ?? readString(record, "transfer_name"), - totalBytes: readNumberLike(record, "totalBytes") ?? readNumberLike(record, "total_bytes"), - height: readNumberLike(record, "height"), - width: readNumberLike(record, "width"), - originalROWID: readNumberLike(record, "originalROWID") ?? readNumberLike(record, "rowid"), - }); - } - return out; -} - -// Apple UTIs used by BlueBubbles for voice notes / audio attachments. Webhook -// payloads sometimes carry only a UTI without a normalized `audio/*` MIME -// (notably iMessage voice notes recorded on macOS 26 Tahoe), so audio -// detection must consult both. Intentionally narrow: covers what BB emits for -// iMessage voice notes today (m4a/MPEG-4 audio). Broader UTIs like -// `public.aiff-audio`, `public.wav`, `public.mp3` are not iMessage voice-note -// formats and pull in `audio/*` MIME paths anyway. -const APPLE_AUDIO_UTIS = new Set([ - "public.audio", - "public.mpeg-4-audio", - "com.apple.m4a-audio", - "com.apple.coreaudio-format", -]); - -export function isBlueBubblesAudioAttachment(attachment: BlueBubblesAttachment): boolean { - const mime = attachment.mimeType?.trim().toLowerCase(); - if (mime && mime.startsWith("audio/")) { - return true; - } - const uti = attachment.uti?.trim().toLowerCase(); - if (uti && APPLE_AUDIO_UTIS.has(uti)) { - return true; - } - return false; -} - -function buildAttachmentPlaceholder(attachments: BlueBubblesAttachment[]): string { - if (attachments.length === 0) { - return ""; - } - const mimeTypes = attachments.map((entry) => entry.mimeType ?? ""); - const allImages = mimeTypes.every((entry) => entry.startsWith("image/")); - const allVideos = mimeTypes.every((entry) => entry.startsWith("video/")); - const allAudio = attachments.every(isBlueBubblesAudioAttachment); - const tag = allImages - ? "" - : allVideos - ? "" - : allAudio - ? "" - : ""; - const label = allImages ? "image" : allVideos ? "video" : allAudio ? "audio" : "file"; - const suffix = attachments.length === 1 ? label : `${label}s`; - return `${tag} (${attachments.length} ${suffix})`; -} - -export function buildMessagePlaceholder(message: NormalizedWebhookMessage): string { - const attachmentPlaceholder = buildAttachmentPlaceholder(message.attachments ?? []); - if (attachmentPlaceholder) { - return attachmentPlaceholder; - } - if (message.balloonBundleId) { - return ""; - } - return ""; -} - -// Returns inline reply tag like "[[reply_to:4]]" for prepending to message body -export function formatReplyTag(message: { - replyToId?: string; - replyToShortId?: string; -}): string | null { - // Prefer short ID - const rawId = message.replyToShortId || message.replyToId; - if (!rawId) { - return null; - } - return `[[reply_to:${rawId}]]`; -} - -function extractReplyMetadata(message: Record): { - replyToId?: string; - replyToBody?: string; - replyToSender?: string; -} { - const replyRaw = - message["replyTo"] ?? - message["reply_to"] ?? - message["replyToMessage"] ?? - message["reply_to_message"] ?? - message["repliedMessage"] ?? - message["quotedMessage"] ?? - message["associatedMessage"] ?? - message["reply"]; - const replyRecord = asRecord(replyRaw); - const replyHandle = - asRecord(replyRecord?.["handle"]) ?? asRecord(replyRecord?.["sender"]) ?? null; - const replySenderRaw = - readString(replyHandle, "address") ?? - readString(replyHandle, "handle") ?? - readString(replyHandle, "id") ?? - readString(replyRecord, "senderId") ?? - readString(replyRecord, "sender") ?? - readString(replyRecord, "from"); - const normalizedSender = replySenderRaw - ? normalizeBlueBubblesHandle(replySenderRaw) || replySenderRaw.trim() - : undefined; - - const replyToBody = - readString(replyRecord, "text") ?? - readString(replyRecord, "body") ?? - readString(replyRecord, "message") ?? - readString(replyRecord, "subject") ?? - undefined; - - const directReplyId = - readString(message, "replyToMessageGuid") ?? - readString(message, "replyToGuid") ?? - readString(message, "replyGuid") ?? - readString(message, "selectedMessageGuid") ?? - readString(message, "selectedMessageId") ?? - readString(message, "replyToMessageId") ?? - readString(message, "replyId") ?? - readString(replyRecord, "guid") ?? - readString(replyRecord, "id") ?? - readString(replyRecord, "messageId"); - - const associatedType = - readNumberLike(message, "associatedMessageType") ?? - readNumberLike(message, "associated_message_type"); - const associatedGuid = - readString(message, "associatedMessageGuid") ?? - readString(message, "associated_message_guid") ?? - readString(message, "associatedMessageId"); - const isReactionAssociation = - typeof associatedType === "number" && REACTION_TYPE_MAP.has(associatedType); - - const replyToId = directReplyId ?? (!isReactionAssociation ? associatedGuid : undefined); - const threadOriginatorGuid = readString(message, "threadOriginatorGuid"); - const messageGuid = readString(message, "guid"); - const fallbackReplyId = - !replyToId && threadOriginatorGuid && threadOriginatorGuid !== messageGuid - ? threadOriginatorGuid - : undefined; - - return { - replyToId: normalizeOptionalString(replyToId ?? fallbackReplyId), - replyToBody: normalizeOptionalString(replyToBody), - replyToSender: normalizedSender || undefined, - }; -} - -function readFirstChatRecord(message: Record): Record | null { - const chats = message["chats"]; - if (!Array.isArray(chats) || chats.length === 0) { - return null; - } - const first = chats[0]; - return asRecord(first); -} - -function readParticipantEntries(record: Record | null): unknown[] | undefined { - if (!record) { - return undefined; - } - const participants = record["participants"]; - if (Array.isArray(participants)) { - return participants; - } - const handles = record["handles"]; - if (Array.isArray(handles)) { - return handles; - } - const participantHandles = record["participantHandles"]; - if (Array.isArray(participantHandles)) { - return participantHandles; - } - return undefined; -} - -function extractSenderInfo(message: Record): { - senderId: string; - senderIdExplicit: boolean; - senderName?: string; -} { - const handleValue = message.handle ?? message.sender; - const handle = - asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null); - const senderIdRaw = - readString(handle, "address") ?? - readString(handle, "handle") ?? - readString(handle, "id") ?? - readString(message, "senderId") ?? - readString(message, "sender") ?? - readString(message, "from") ?? - ""; - const senderId = senderIdRaw.trim(); - const senderName = - readString(handle, "displayName") ?? - readString(handle, "name") ?? - readString(message, "senderName") ?? - undefined; - - return { - senderId, - senderIdExplicit: Boolean(senderId), - senderName, - }; -} - -function extractChatContext(message: Record): { - chatGuid?: string; - chatIdentifier?: string; - chatId?: number; - chatName?: string; - isGroup: boolean; - participants: unknown[]; -} { - const chat = asRecord(message.chat) ?? asRecord(message.conversation) ?? null; - const chatFromList = readFirstChatRecord(message); - const chatGuid = - readString(message, "chatGuid") ?? - readString(message, "chat_guid") ?? - readString(chat, "chatGuid") ?? - readString(chat, "chat_guid") ?? - readString(chat, "guid") ?? - readString(chatFromList, "chatGuid") ?? - readString(chatFromList, "chat_guid") ?? - readString(chatFromList, "guid"); - const chatIdentifier = - readString(message, "chatIdentifier") ?? - readString(message, "chat_identifier") ?? - readString(chat, "chatIdentifier") ?? - readString(chat, "chat_identifier") ?? - readString(chat, "identifier") ?? - readString(chatFromList, "chatIdentifier") ?? - readString(chatFromList, "chat_identifier") ?? - readString(chatFromList, "identifier") ?? - extractChatIdentifierFromChatGuid(chatGuid); - const chatId = - readNumberLike(message, "chatId") ?? - readNumberLike(message, "chat_id") ?? - readNumberLike(chat, "chatId") ?? - readNumberLike(chat, "chat_id") ?? - readNumberLike(chat, "id") ?? - readNumberLike(chatFromList, "chatId") ?? - readNumberLike(chatFromList, "chat_id") ?? - readNumberLike(chatFromList, "id"); - const chatName = - readString(message, "chatName") ?? - readString(chat, "displayName") ?? - readString(chat, "name") ?? - readString(chatFromList, "displayName") ?? - readString(chatFromList, "name") ?? - undefined; - - const participants = - readParticipantEntries(chat) ?? - readParticipantEntries(message) ?? - readParticipantEntries(chatFromList) ?? - []; - const participantsCount = participants.length; - const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid); - const explicitIsGroup = - readBoolean(message, "isGroup") ?? - readBoolean(message, "is_group") ?? - readBoolean(chat, "isGroup") ?? - readBoolean(message, "group"); - const isGroup = - typeof groupFromChatGuid === "boolean" - ? groupFromChatGuid - : (explicitIsGroup ?? participantsCount > 2); - - return { - chatGuid, - chatIdentifier, - chatId, - chatName, - isGroup, - participants, - }; -} - -function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | null { - if (typeof entry === "string" || typeof entry === "number") { - const raw = String(entry).trim(); - if (!raw) { - return null; - } - const normalized = normalizeBlueBubblesHandle(raw) || raw; - return normalized ? { id: normalized } : null; - } - const record = asRecord(entry); - if (!record) { - return null; - } - const nestedHandle = - asRecord(record["handle"]) ?? asRecord(record["sender"]) ?? asRecord(record["contact"]) ?? null; - const idRaw = - readString(record, "address") ?? - readString(record, "handle") ?? - readString(record, "id") ?? - readString(record, "phoneNumber") ?? - readString(record, "phone_number") ?? - readString(record, "email") ?? - readString(nestedHandle, "address") ?? - readString(nestedHandle, "handle") ?? - readString(nestedHandle, "id"); - const nameRaw = - readString(record, "displayName") ?? - readString(record, "name") ?? - readString(record, "title") ?? - readString(nestedHandle, "displayName") ?? - readString(nestedHandle, "name"); - const normalizedId = idRaw ? normalizeBlueBubblesHandle(idRaw) || idRaw.trim() : ""; - if (!normalizedId) { - return null; - } - const name = normalizeOptionalString(nameRaw); - return { id: normalizedId, name }; -} - -export function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] { - const entries = Array.isArray(raw) ? raw : (readParticipantEntries(asRecord(raw)) ?? []); - if (entries.length === 0) { - return []; - } - const seen = new Set(); - const output: BlueBubblesParticipant[] = []; - for (const entry of entries) { - const normalized = normalizeParticipantEntry(entry); - if (!normalized?.id) { - continue; - } - const key = normalizeLowercaseStringOrEmpty(normalized.id); - if (seen.has(key)) { - continue; - } - seen.add(key); - output.push(normalized); - } - return output; -} - -export function formatGroupMembers(params: { - participants?: BlueBubblesParticipant[]; - fallback?: BlueBubblesParticipant; -}): string | undefined { - const seen = new Set(); - const ordered: BlueBubblesParticipant[] = []; - for (const entry of params.participants ?? []) { - if (!entry?.id) { - continue; - } - const key = normalizeLowercaseStringOrEmpty(entry.id); - if (seen.has(key)) { - continue; - } - seen.add(key); - ordered.push(entry); - } - if (ordered.length === 0 && params.fallback?.id) { - ordered.push(params.fallback); - } - if (ordered.length === 0) { - return undefined; - } - return ordered.map((entry) => (entry.name ? `${entry.name} (${entry.id})` : entry.id)).join(", "); -} - -export function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undefined { - const guid = chatGuid?.trim(); - if (!guid) { - return undefined; - } - const parts = guid.split(";"); - if (parts.length >= 3) { - if (parts[1] === "+") { - return true; - } - if (parts[1] === "-") { - return false; - } - } - if (guid.includes(";+;")) { - return true; - } - if (guid.includes(";-;")) { - return false; - } - return undefined; -} - -function extractChatIdentifierFromChatGuid(chatGuid?: string | null): string | undefined { - const guid = chatGuid?.trim(); - if (!guid) { - return undefined; - } - const parts = guid.split(";"); - if (parts.length < 3) { - return undefined; - } - const identifier = parts[2]?.trim(); - return identifier || undefined; -} - -export function formatGroupAllowlistEntry(params: { - chatGuid?: string; - chatId?: number; - chatIdentifier?: string; -}): string | null { - const guid = params.chatGuid?.trim(); - if (guid) { - return `chat_guid:${guid}`; - } - const chatId = params.chatId; - if (typeof chatId === "number" && Number.isFinite(chatId)) { - return `chat_id:${chatId}`; - } - const identifier = params.chatIdentifier?.trim(); - if (identifier) { - return `chat_identifier:${identifier}`; - } - return null; -} - -export type BlueBubblesParticipant = { - id: string; - name?: string; -}; - -export type NormalizedWebhookMessage = { - text: string; - senderId: string; - senderIdExplicit: boolean; - senderName?: string; - messageId?: string; - timestamp?: number; - isGroup: boolean; - chatId?: number; - chatGuid?: string; - chatIdentifier?: string; - chatName?: string; - fromMe?: boolean; - attachments?: BlueBubblesAttachment[]; - balloonBundleId?: string; - associatedMessageGuid?: string; - associatedMessageType?: number; - associatedMessageEmoji?: string; - isTapback?: boolean; - participants?: BlueBubblesParticipant[]; - replyToId?: string; - replyToBody?: string; - replyToSender?: string; - /** Webhook event type preserved for dedup key differentiation. */ - eventType?: string; - /** - * When the debouncer merges multiple source webhook events into one - * processed message (see `combineDebounceEntries` in `monitor-debounce.ts`), - * this preserves every source `messageId` that contributed to the merged - * view. Downstream inbound-dedupe commits all of them so a later BlueBubbles - * MessagePoller replay of any individual source event is recognized as a - * duplicate rather than re-processed. Unset for single-event messages. - */ - coalescedMessageIds?: string[]; -}; - -export type NormalizedWebhookReaction = { - action: "added" | "removed"; - emoji: string; - senderId: string; - senderIdExplicit: boolean; - senderName?: string; - messageId: string; - timestamp?: number; - isGroup: boolean; - chatId?: number; - chatGuid?: string; - chatIdentifier?: string; - chatName?: string; - fromMe?: boolean; -}; - -const REACTION_TYPE_MAP = new Map([ - [2000, { emoji: "❤️", action: "added" }], - [2001, { emoji: "👍", action: "added" }], - [2002, { emoji: "👎", action: "added" }], - [2003, { emoji: "😂", action: "added" }], - [2004, { emoji: "‼️", action: "added" }], - [2005, { emoji: "❓", action: "added" }], - [3000, { emoji: "❤️", action: "removed" }], - [3001, { emoji: "👍", action: "removed" }], - [3002, { emoji: "👎", action: "removed" }], - [3003, { emoji: "😂", action: "removed" }], - [3004, { emoji: "‼️", action: "removed" }], - [3005, { emoji: "❓", action: "removed" }], -]); - -// Maps tapback text patterns (e.g., "Loved", "Liked") to emoji + action -const TAPBACK_TEXT_MAP = new Map([ - ["loved", { emoji: "❤️", action: "added" }], - ["liked", { emoji: "👍", action: "added" }], - ["disliked", { emoji: "👎", action: "added" }], - ["laughed at", { emoji: "😂", action: "added" }], - ["emphasized", { emoji: "‼️", action: "added" }], - ["questioned", { emoji: "❓", action: "added" }], - // Removal patterns (e.g., "Removed a heart from") - ["removed a heart from", { emoji: "❤️", action: "removed" }], - ["removed a like from", { emoji: "👍", action: "removed" }], - ["removed a dislike from", { emoji: "👎", action: "removed" }], - ["removed a laugh from", { emoji: "😂", action: "removed" }], - ["removed an emphasis from", { emoji: "‼️", action: "removed" }], - ["removed a question from", { emoji: "❓", action: "removed" }], -]); - -const TAPBACK_EMOJI_REGEX = - /(?:\p{Regional_Indicator}{2})|(?:[0-9#*]\uFE0F?\u20E3)|(?:\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?)*)/u; - -function extractFirstEmoji(text: string): string | null { - const match = text.match(TAPBACK_EMOJI_REGEX); - return match ? match[0] : null; -} - -function extractQuotedTapbackText(text: string): string | null { - const match = text.match(/[“"]([^”"]+)[”"]/s); - return match ? match[1] : null; -} - -function isTapbackAssociatedType(type: number | undefined): boolean { - return typeof type === "number" && Number.isFinite(type) && type >= 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; -} - -export 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 = - normalizeOptionalString(message.associatedMessageGuid) ?? - normalizeOptionalString(message.replyToId); - 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 -export function parseTapbackText(params: { - text: string; - emojiHint?: string; - actionHint?: "added" | "removed"; - requireQuoted?: boolean; -}): { - emoji: string; - action: "added" | "removed"; - quotedText: string; -} | null { - const trimmed = params.text.trim(); - const lower = normalizeLowercaseStringOrEmpty(trimmed); - if (!trimmed) { - return null; - } - - const parseLeadingReactionAction = ( - prefix: "reacted" | "removed", - defaultAction: "added" | "removed", - ) => { - if (!lower.startsWith(prefix)) { - return null; - } - const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint; - if (!emoji) { - return null; - } - const quotedText = extractQuotedTapbackText(trimmed); - if (params.requireQuoted && !quotedText) { - return null; - } - const fallback = trimmed.slice(prefix.length).trim(); - return { - emoji, - action: params.actionHint ?? defaultAction, - quotedText: quotedText ?? fallback, - }; - }; - - 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(); - 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 }; - } - } - - const reacted = parseLeadingReactionAction("reacted", "added"); - if (reacted) { - return reacted; - } - - const removed = parseLeadingReactionAction("removed", "removed"); - if (removed) { - return removed; - } - return null; -} - -function extractMessagePayload(payload: Record): Record | null { - const parseRecord = (value: unknown): Record | null => { - const record = asRecord(value); - if (record) { - return record; - } - if (Array.isArray(value)) { - for (const entry of value) { - const parsedEntry = parseRecord(entry); - if (parsedEntry) { - return parsedEntry; - } - } - return null; - } - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - try { - return parseRecord(JSON.parse(trimmed)); - } catch { - return null; - } - }; - - const dataRaw = payload.data ?? payload.payload ?? payload.event; - const data = parseRecord(dataRaw); - const messageRaw = payload.message ?? data?.message ?? data; - const message = parseRecord(messageRaw); - if (message) { - return message; - } - return null; -} - -export function normalizeWebhookMessage( - payload: Record, - options?: { eventType?: string }, -): NormalizedWebhookMessage | null { - const message = extractMessagePayload(payload); - if (!message) { - return null; - } - - const text = - readString(message, "text") ?? - readString(message, "body") ?? - readString(message, "subject") ?? - ""; - - const { senderId, senderIdExplicit, senderName } = extractSenderInfo(message); - const { chatGuid, chatIdentifier, chatId, chatName, isGroup, participants } = - extractChatContext(message); - const normalizedParticipants = normalizeParticipantList(participants); - - const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me"); - const messageId = - readString(message, "guid") ?? - readString(message, "id") ?? - 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") ?? - readNumber(message, "dateCreated") ?? - readNumber(message, "timestamp"); - const timestamp = - typeof timestampRaw === "number" - ? timestampRaw > 1_000_000_000_000 - ? timestampRaw - : timestampRaw * 1000 - : undefined; - - // BlueBubbles may omit `handle` in webhook payloads; for DM chat GUIDs we can still infer sender. - const senderFallbackFromChatGuid = - !senderIdExplicit && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null; - const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || ""); - if (!normalizedSender) { - return null; - } - const replyMetadata = extractReplyMetadata(message); - - return { - text, - senderId: normalizedSender, - senderIdExplicit, - senderName, - messageId, - timestamp, - isGroup, - chatId, - chatGuid, - chatIdentifier, - chatName, - fromMe, - attachments: extractAttachments(message), - balloonBundleId, - associatedMessageGuid, - associatedMessageType, - associatedMessageEmoji, - isTapback, - participants: normalizedParticipants, - replyToId: replyMetadata.replyToId, - replyToBody: replyMetadata.replyToBody, - replyToSender: replyMetadata.replyToSender, - eventType: options?.eventType, - }; -} - -export function normalizeWebhookReaction( - payload: Record, -): NormalizedWebhookReaction | null { - const message = extractMessagePayload(payload); - if (!message) { - return null; - } - - const associatedGuid = - readString(message, "associatedMessageGuid") ?? - readString(message, "associated_message_guid") ?? - readString(message, "associatedMessageId"); - const associatedType = - readNumberLike(message, "associatedMessageType") ?? - readNumberLike(message, "associated_message_type"); - if (!associatedGuid || associatedType === undefined) { - return null; - } - - const mapping = REACTION_TYPE_MAP.get(associatedType); - 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 { senderId, senderIdExplicit, senderName } = extractSenderInfo(message); - const { chatGuid, chatIdentifier, chatId, chatName, isGroup } = extractChatContext(message); - - const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me"); - const timestampRaw = - readNumberLike(message, "date") ?? - readNumberLike(message, "dateCreated") ?? - readNumberLike(message, "timestamp"); - const timestamp = - typeof timestampRaw === "number" - ? timestampRaw > 1_000_000_000_000 - ? timestampRaw - : timestampRaw * 1000 - : undefined; - - const senderFallbackFromChatGuid = - !senderIdExplicit && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null; - const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || ""); - if (!normalizedSender) { - return null; - } - - return { - action, - emoji, - senderId: normalizedSender, - senderIdExplicit, - senderName, - messageId: associatedGuid, - timestamp, - isGroup, - chatId, - chatGuid, - chatIdentifier, - chatName, - fromMe, - }; -} diff --git a/extensions/bluebubbles/src/monitor-processing-api.ts b/extensions/bluebubbles/src/monitor-processing-api.ts deleted file mode 100644 index 3794f3c28279..000000000000 --- a/extensions/bluebubbles/src/monitor-processing-api.ts +++ /dev/null @@ -1,20 +0,0 @@ -export { resolveAckReaction } from "openclaw/plugin-sdk/channel-feedback"; -export { logAckFailure, logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; -export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound"; -export { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; -export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; -export { deriveDurableFinalDeliveryRequirements } from "openclaw/plugin-sdk/channel-message"; -export { - DM_GROUP_ACCESS_REASON, - readStoreAllowFromForDmPolicy, - resolveDmGroupAccessWithLists, -} from "openclaw/plugin-sdk/channel-policy"; -export { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; -export { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/context-visibility-runtime"; -export { - evictOldHistoryKeys, - recordPendingHistoryEntryIfEnabled, - type HistoryEntry, -} from "openclaw/plugin-sdk/reply-history"; -export { evaluateSupplementalContextVisibility } from "openclaw/plugin-sdk/security-runtime"; -export { stripMarkdown } from "openclaw/plugin-sdk/text-runtime"; diff --git a/extensions/bluebubbles/src/monitor-processing-chat-resolve.test.ts b/extensions/bluebubbles/src/monitor-processing-chat-resolve.test.ts deleted file mode 100644 index 7b8c0b4be8e6..000000000000 --- a/extensions/bluebubbles/src/monitor-processing-chat-resolve.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - _sanitizeBlueBubblesLogValueForTest, - buildBlueBubblesInboundChatResolveTarget, -} from "./monitor-processing.js"; - -describe("buildBlueBubblesInboundChatResolveTarget", () => { - it("uses chat_id for group inbound when chatId is present", () => { - const target = buildBlueBubblesInboundChatResolveTarget({ - isGroup: true, - chatId: 42, - chatIdentifier: undefined, - senderId: "+15551234567", - }); - expect(target).toEqual({ kind: "chat_id", chatId: 42 }); - }); - - it("uses chat_identifier for group inbound when chatId missing but identifier present", () => { - const target = buildBlueBubblesInboundChatResolveTarget({ - isGroup: true, - chatId: undefined, - chatIdentifier: "iMessage;+;chat-abc", - senderId: "+15551234567", - }); - expect(target).toEqual({ - kind: "chat_identifier", - chatIdentifier: "iMessage;+;chat-abc", - }); - }); - - it("prefers chat_id over chat_identifier when both are present for a group", () => { - const target = buildBlueBubblesInboundChatResolveTarget({ - isGroup: true, - chatId: 7, - chatIdentifier: "iMessage;+;chat-abc", - senderId: "+15551234567", - }); - expect(target).toEqual({ kind: "chat_id", chatId: 7 }); - }); - - it("REFUSES sender-handle fallback for group inbound with no chat identifiers", () => { - // This is the candidate-4 regression: BlueBubbles webhooks for tapbacks - // and certain reaction/updated-message events arrive without chatGuid/ - // chatId/chatIdentifier. Falling through to { kind: "handle", - // address: senderId } would resolve the sender's DM chatGuid and - // poison every action keyed off it (ack reaction, mark-read, outbound - // reply cache), making group reactions land in DMs. - const target = buildBlueBubblesInboundChatResolveTarget({ - isGroup: true, - chatId: undefined, - chatIdentifier: undefined, - senderId: "+15551234567", - }); - expect(target).toBeNull(); - }); - - it("treats blank chatIdentifier as missing for group inbound", () => { - const target = buildBlueBubblesInboundChatResolveTarget({ - isGroup: true, - chatId: undefined, - chatIdentifier: " ", - senderId: "+15551234567", - }); - expect(target).toBeNull(); - }); - - it("treats non-finite chatId as missing for group inbound", () => { - const target = buildBlueBubblesInboundChatResolveTarget({ - isGroup: true, - chatId: Number.NaN, - chatIdentifier: undefined, - senderId: "+15551234567", - }); - expect(target).toBeNull(); - }); - - it("treats null chatId/chatIdentifier as missing for group inbound", () => { - const target = buildBlueBubblesInboundChatResolveTarget({ - isGroup: true, - chatId: null, - chatIdentifier: null, - senderId: "+15551234567", - }); - expect(target).toBeNull(); - }); - - it("uses sender handle for DM inbound (the chat IS the conversation with that sender)", () => { - const target = buildBlueBubblesInboundChatResolveTarget({ - isGroup: false, - chatId: undefined, - chatIdentifier: undefined, - senderId: "+15551234567", - }); - expect(target).toEqual({ kind: "handle", address: "+15551234567" }); - }); - - it("uses sender handle for DM inbound even when chatId is present (preserves prior behavior)", () => { - const target = buildBlueBubblesInboundChatResolveTarget({ - isGroup: false, - chatId: 99, - chatIdentifier: "iMessage;-;+15551234567", - senderId: "+15551234567", - }); - expect(target).toEqual({ kind: "handle", address: "+15551234567" }); - }); - - it("returns null for DM inbound with empty senderId", () => { - const target = buildBlueBubblesInboundChatResolveTarget({ - isGroup: false, - chatId: undefined, - chatIdentifier: undefined, - senderId: " ", - }); - expect(target).toBeNull(); - }); -}); - -describe("BlueBubbles monitor log sanitization", () => { - it("redacts BlueBubbles query auth and Authorization headers", () => { - const input = - "GET /api/v1/attachment?password=secret&guid=socket-secret&token=api-token Authorization: Bearer abc123"; - - const sanitized = _sanitizeBlueBubblesLogValueForTest(input); - - expect(sanitized).toContain("password="); - expect(sanitized).toContain("guid="); - expect(sanitized).toContain("token="); - expect(sanitized).toContain("Authorization: Bearer "); - expect(sanitized).not.toContain("secret"); - expect(sanitized).not.toContain("api-token"); - expect(sanitized).not.toContain("abc123"); - }); - - it("strips control characters before logging", () => { - expect(_sanitizeBlueBubblesLogValueForTest("one\ntwo\tt\u0000hree")).toBe("one two t hree"); - }); -}); diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts deleted file mode 100644 index 5c39fbcccda7..000000000000 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ /dev/null @@ -1,2218 +0,0 @@ -import { - resolveOutboundMediaUrls, - resolveTextChunksWithFallback, - sendMediaWithLeadingCaption, - type ReplyPayload, -} from "openclaw/plugin-sdk/reply-payload"; -import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalLowercaseString, - normalizeOptionalString, -} from "openclaw/plugin-sdk/string-coerce-runtime"; -import { - downloadBlueBubblesAttachment, - fetchBlueBubblesMessageAttachments, -} from "./attachments.js"; -import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js"; -import { createBlueBubblesClientFromParts } from "./client.js"; -import { resolveBlueBubblesConversationRoute } from "./conversation-route.js"; -import { fetchBlueBubblesHistory } from "./history.js"; -import { - claimBlueBubblesInboundMessage, - commitBlueBubblesCoalescedMessageIds, - resolveBlueBubblesInboundDedupeKey, -} from "./inbound-dedupe.js"; -import { sendBlueBubblesMedia } from "./media-send.js"; -import { - buildMessagePlaceholder, - formatGroupAllowlistEntry, - formatGroupMembers, - formatReplyTag, - normalizeParticipantList, - parseTapbackText, - resolveGroupFlagFromChatGuid, - resolveTapbackContext, - type NormalizedWebhookMessage, - type NormalizedWebhookReaction, -} from "./monitor-normalize.js"; -import { - DM_GROUP_ACCESS_REASON, - createChannelPairingController, - deriveDurableFinalDeliveryRequirements, - evictOldHistoryKeys, - evaluateSupplementalContextVisibility, - logAckFailure, - logInboundDrop, - logTypingFailure, - mapAllowFromEntries, - readStoreAllowFromForDmPolicy, - recordPendingHistoryEntryIfEnabled, - resolveAckReaction, - resolveChannelContextVisibilityMode, - resolveDmGroupAccessWithLists, - resolveControlCommandGate, - stripMarkdown, - type HistoryEntry, -} from "./monitor-processing-api.js"; -import { - getShortIdForUuid, - rememberBlueBubblesReplyCache, - resolveBlueBubblesMessageId, - resolveReplyContextFromCache, -} from "./monitor-reply-cache.js"; -import { fetchBlueBubblesReplyContext } from "./monitor-reply-fetch.js"; -import { - hasBlueBubblesSelfChatCopy, - rememberBlueBubblesSelfChatCopy, -} from "./monitor-self-chat-cache.js"; -import type { - BlueBubblesCoreRuntime, - BlueBubblesRuntimeEnv, - WebhookTarget, -} from "./monitor-shared.js"; -import { enrichBlueBubblesParticipantsWithContactNames } from "./participant-contact-names.js"; -import { isBlueBubblesPrivateApiEnabled } from "./probe.js"; -import { normalizeBlueBubblesReactionInputStrict, sendBlueBubblesReaction } from "./reactions.js"; -import type { OpenClawConfig } from "./runtime-api.js"; -import { normalizeSecretInputString } from "./secret-input.js"; -import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; -import { - extractHandleFromChatGuid, - formatBlueBubblesChatTarget, - isAllowedBlueBubblesSender, - normalizeBlueBubblesHandle, -} from "./targets.js"; - -const DEFAULT_TEXT_LIMIT = 4000; -const invalidAckReactions = new Set(); -const REPLY_DIRECTIVE_TAG_RE = /\[\[\s*(?:reply_to_current|reply_to\s*:\s*[^\]\n]+)\s*\]\]/gi; -const PENDING_OUTBOUND_MESSAGE_ID_TTL_MS = 2 * 60 * 1000; - -type PendingOutboundMessageId = { - id: number; - accountId: string; - sessionKey: string; - outboundTarget: string; - chatGuid?: string; - chatIdentifier?: string; - chatId?: number; - snippetRaw: string; - snippetNorm: string; - isMediaSnippet: boolean; - createdAt: number; -}; - -const pendingOutboundMessageIds: PendingOutboundMessageId[] = []; -let pendingOutboundMessageIdCounter = 0; - -function normalizeSnippet(value: string): string { - return normalizeOptionalLowercaseString(stripMarkdown(value).replace(/\s+/g, " ")) ?? ""; -} - -type BlueBubblesChatRecord = Record; - -function extractBlueBubblesChatGuid(chat: BlueBubblesChatRecord): string | undefined { - const candidates = [chat.chatGuid, chat.guid, chat.chat_guid]; - for (const candidate of candidates) { - if (typeof candidate === "string" && candidate.trim()) { - return candidate.trim(); - } - } - return undefined; -} - -function extractBlueBubblesChatId(chat: BlueBubblesChatRecord): number | undefined { - const candidates = [chat.chatId, chat.id, chat.chat_id]; - for (const candidate of candidates) { - if (typeof candidate === "number" && Number.isFinite(candidate)) { - return candidate; - } - } - return undefined; -} - -function extractChatIdentifierFromChatGuid(chatGuid: string): string | undefined { - const parts = chatGuid.split(";"); - if (parts.length < 3) { - return undefined; - } - const identifier = parts[2]?.trim(); - return identifier || undefined; -} - -function extractBlueBubblesChatIdentifier(chat: BlueBubblesChatRecord): string | undefined { - const candidates = [chat.chatIdentifier, chat.chat_identifier, chat.identifier]; - for (const candidate of candidates) { - if (typeof candidate === "string" && candidate.trim()) { - return candidate.trim(); - } - } - const chatGuid = extractBlueBubblesChatGuid(chat); - return chatGuid ? extractChatIdentifierFromChatGuid(chatGuid) : undefined; -} - -async function queryBlueBubblesChats(params: { - baseUrl: string; - password: string; - timeoutMs?: number; - offset: number; - limit: number; - allowPrivateNetwork?: boolean; -}): Promise { - const client = createBlueBubblesClientFromParts({ - baseUrl: params.baseUrl, - password: params.password, - allowPrivateNetwork: params.allowPrivateNetwork === true, - timeoutMs: params.timeoutMs, - }); - const res = await client.request({ - method: "POST", - path: "/api/v1/chat/query", - body: { - limit: params.limit, - offset: params.offset, - with: ["participants"], - }, - timeoutMs: params.timeoutMs, - }); - if (!res.ok) { - return []; - } - const payload = (await res.json().catch(() => null)) as Record | null; - const data = payload && payload.data !== undefined ? (payload.data as unknown) : null; - return Array.isArray(data) ? (data as BlueBubblesChatRecord[]) : []; -} - -async function fetchBlueBubblesParticipantsForInboundMessage(params: { - baseUrl: string; - password: string; - chatGuid?: string; - chatId?: number; - chatIdentifier?: string; - allowPrivateNetwork?: boolean; -}): Promise { - if (!params.chatGuid && params.chatId == null && !params.chatIdentifier) { - return null; - } - - const limit = 500; - for (let offset = 0; offset < 5000; offset += limit) { - const chats = await queryBlueBubblesChats({ - baseUrl: params.baseUrl, - password: params.password, - offset, - limit, - allowPrivateNetwork: params.allowPrivateNetwork, - }); - if (chats.length === 0) { - return null; - } - - for (const chat of chats) { - const chatGuid = extractBlueBubblesChatGuid(chat); - const chatId = extractBlueBubblesChatId(chat); - const chatIdentifier = extractBlueBubblesChatIdentifier(chat); - const matches = - (params.chatGuid && chatGuid === params.chatGuid) || - (params.chatId != null && chatId === params.chatId) || - (params.chatIdentifier && - (chatIdentifier === params.chatIdentifier || chatGuid === params.chatIdentifier)); - if (matches) { - return normalizeParticipantList(chat); - } - } - - if (chats.length < limit) { - return null; - } - } - - return null; -} - -function isBlueBubblesSelfChatMessage( - message: NormalizedWebhookMessage, - isGroup: boolean, -): boolean { - if (isGroup || !message.senderIdExplicit) { - return false; - } - const chatHandle = - (message.chatGuid ? extractHandleFromChatGuid(message.chatGuid) : null) ?? - normalizeBlueBubblesHandle(message.chatIdentifier ?? ""); - return Boolean(chatHandle) && chatHandle === message.senderId; -} - -function prunePendingOutboundMessageIds(now = Date.now()): void { - const cutoff = now - PENDING_OUTBOUND_MESSAGE_ID_TTL_MS; - for (let i = pendingOutboundMessageIds.length - 1; i >= 0; i--) { - if (pendingOutboundMessageIds[i].createdAt < cutoff) { - pendingOutboundMessageIds.splice(i, 1); - } - } -} - -function rememberPendingOutboundMessageId(entry: { - accountId: string; - sessionKey: string; - outboundTarget: string; - chatGuid?: string; - chatIdentifier?: string; - chatId?: number; - snippet: string; -}): number { - prunePendingOutboundMessageIds(); - pendingOutboundMessageIdCounter += 1; - const snippetRaw = entry.snippet.trim(); - const snippetNorm = normalizeSnippet(snippetRaw); - pendingOutboundMessageIds.push({ - id: pendingOutboundMessageIdCounter, - accountId: entry.accountId, - sessionKey: entry.sessionKey, - outboundTarget: entry.outboundTarget, - chatGuid: normalizeOptionalString(entry.chatGuid), - chatIdentifier: normalizeOptionalString(entry.chatIdentifier), - chatId: typeof entry.chatId === "number" ? entry.chatId : undefined, - snippetRaw, - snippetNorm, - isMediaSnippet: normalizeLowercaseStringOrEmpty(snippetRaw).startsWith(" entry.id === id); - if (index >= 0) { - pendingOutboundMessageIds.splice(index, 1); - } -} - -function chatsMatch( - left: Pick, - right: { chatGuid?: string; chatIdentifier?: string; chatId?: number }, -): boolean { - const leftGuid = normalizeOptionalString(left.chatGuid); - const rightGuid = normalizeOptionalString(right.chatGuid); - if (leftGuid && rightGuid) { - return leftGuid === rightGuid; - } - - const leftIdentifier = normalizeOptionalString(left.chatIdentifier); - const rightIdentifier = normalizeOptionalString(right.chatIdentifier); - if (leftIdentifier && rightIdentifier) { - return leftIdentifier === rightIdentifier; - } - - const leftChatId = typeof left.chatId === "number" ? left.chatId : undefined; - const rightChatId = typeof right.chatId === "number" ? right.chatId : undefined; - if (leftChatId !== undefined && rightChatId !== undefined) { - return leftChatId === rightChatId; - } - - return false; -} - -function consumePendingOutboundMessageId(params: { - accountId: string; - chatGuid?: string; - chatIdentifier?: string; - chatId?: number; - body: string; -}): PendingOutboundMessageId | null { - prunePendingOutboundMessageIds(); - const bodyNorm = normalizeSnippet(params.body); - const isMediaBody = normalizeLowercaseStringOrEmpty(params.body).startsWith("(); -type HistoryBackfillState = { - attempts: number; - firstAttemptAt: number; - nextAttemptAt: number; - resolved: boolean; -}; - -const historyBackfills = new Map(); -const HISTORY_BACKFILL_BASE_DELAY_MS = 5_000; -const HISTORY_BACKFILL_MAX_DELAY_MS = 2 * 60 * 1000; -const HISTORY_BACKFILL_MAX_ATTEMPTS = 6; -const HISTORY_BACKFILL_RETRY_WINDOW_MS = 30 * 60 * 1000; -const MAX_STORED_HISTORY_ENTRY_CHARS = 2_000; -const MAX_INBOUND_HISTORY_ENTRY_CHARS = 1_200; -const MAX_INBOUND_HISTORY_TOTAL_CHARS = 12_000; - -function buildAccountScopedHistoryKey(accountId: string, historyIdentifier: string): string { - return `${accountId}\u0000${historyIdentifier}`; -} - -function historyDedupKey(entry: HistoryEntry): string { - const messageId = entry.messageId?.trim(); - if (messageId) { - return `id:${messageId}`; - } - return `fallback:${entry.sender}\u0000${entry.body}\u0000${entry.timestamp ?? ""}`; -} - -function truncateHistoryBody(body: string, maxChars: number): string { - const trimmed = body.trim(); - if (!trimmed) { - return ""; - } - if (trimmed.length <= maxChars) { - return trimmed; - } - return `${trimmed.slice(0, maxChars).trimEnd()}...`; -} - -function mergeHistoryEntries(params: { - apiEntries: HistoryEntry[]; - currentEntries: HistoryEntry[]; - limit: number; -}): HistoryEntry[] { - if (params.limit <= 0) { - return []; - } - - const merged: HistoryEntry[] = []; - const seen = new Set(); - const appendUnique = (entry: HistoryEntry) => { - const key = historyDedupKey(entry); - if (seen.has(key)) { - return; - } - seen.add(key); - merged.push(entry); - }; - - for (const entry of params.apiEntries) { - appendUnique(entry); - } - for (const entry of params.currentEntries) { - appendUnique(entry); - } - - if (merged.length <= params.limit) { - return merged; - } - return merged.slice(merged.length - params.limit); -} - -function pruneHistoryBackfillState(): void { - for (const key of historyBackfills.keys()) { - if (!chatHistories.has(key)) { - historyBackfills.delete(key); - } - } -} - -function markHistoryBackfillResolved(historyKey: string): void { - const state = historyBackfills.get(historyKey); - if (state) { - state.resolved = true; - historyBackfills.set(historyKey, state); - return; - } - historyBackfills.set(historyKey, { - attempts: 0, - firstAttemptAt: Date.now(), - nextAttemptAt: Number.POSITIVE_INFINITY, - resolved: true, - }); -} - -function planHistoryBackfillAttempt(historyKey: string, now: number): HistoryBackfillState | null { - const existing = historyBackfills.get(historyKey); - if (existing?.resolved) { - return null; - } - if (existing && now - existing.firstAttemptAt > HISTORY_BACKFILL_RETRY_WINDOW_MS) { - markHistoryBackfillResolved(historyKey); - return null; - } - if (existing && existing.attempts >= HISTORY_BACKFILL_MAX_ATTEMPTS) { - markHistoryBackfillResolved(historyKey); - return null; - } - if (existing && now < existing.nextAttemptAt) { - return null; - } - - const attempts = (existing?.attempts ?? 0) + 1; - const firstAttemptAt = existing?.firstAttemptAt ?? now; - const backoffDelay = Math.min( - HISTORY_BACKFILL_BASE_DELAY_MS * 2 ** (attempts - 1), - HISTORY_BACKFILL_MAX_DELAY_MS, - ); - const state: HistoryBackfillState = { - attempts, - firstAttemptAt, - nextAttemptAt: now + backoffDelay, - resolved: false, - }; - historyBackfills.set(historyKey, state); - return state; -} - -function buildInboundHistorySnapshot(params: { - entries: HistoryEntry[]; - limit: number; -}): Array<{ sender: string; body: string; timestamp?: number }> | undefined { - if (params.limit <= 0 || params.entries.length === 0) { - return undefined; - } - const recent = params.entries.slice(-params.limit); - const selected: Array<{ sender: string; body: string; timestamp?: number }> = []; - let remainingChars = MAX_INBOUND_HISTORY_TOTAL_CHARS; - - for (let i = recent.length - 1; i >= 0; i--) { - const entry = recent[i]; - const body = truncateHistoryBody(entry.body, MAX_INBOUND_HISTORY_ENTRY_CHARS); - if (!body) { - continue; - } - if (selected.length > 0 && body.length > remainingChars) { - break; - } - selected.push({ - sender: entry.sender, - body, - timestamp: entry.timestamp, - }); - remainingChars -= body.length; - if (remainingChars <= 0) { - break; - } - } - - if (selected.length === 0) { - return undefined; - } - selected.reverse(); - return selected; -} - -function sanitizeForLog(value: unknown, maxLen = 200): string { - let cleaned = String(value).replace(/[\r\n\t\p{C}]/gu, " "); - // Redact common secret-bearing patterns before logging. BlueBubbles uses - // query-string auth (`?password=...`, `?guid=...`, or `?token=...`) by - // default, so attachment download failures and similar errors can carry the - // API password in the captured request URL; other libraries occasionally - // surface `Authorization: Bearer ...` headers in error chains. Strip both - // before they reach the log sink (CWE-532). - cleaned = cleaned.replace( - /([?&](?:password|guid|token|api[_-]?key|secret)=)[^&\s"]+/gi, - "$1", - ); - cleaned = cleaned.replace(/(authorization\s*:\s*(?:bearer|basic)\s+)[^\s"]+/gi, "$1"); - return cleaned.length > maxLen ? cleaned.slice(0, maxLen) + "..." : cleaned; -} - -export const _sanitizeBlueBubblesLogValueForTest = sanitizeForLog; - -/** - * Signal object threaded through `processMessageAfterDedupe` so the outer - * wrapper can distinguish "reply delivery failed silently" from "returned - * normally after an intentional drop" (fromMe cache, pairing flow, allowlist - * block, empty text, etc.). - * - * Reply delivery errors in the BlueBubbles path surface through the - * dispatcher's `onError` callback rather than as thrown exceptions, so a - * plain try/catch cannot detect them — see review thread `rwF8` on #66230. - */ -type InboundDedupeDeliverySignal = { deliveryFailed: boolean }; - -/** - * Claim → process → finalize/release wrapper around the real inbound flow. - * - * Claim before doing any work so restart replays and in-flight concurrent - * redeliveries both drop cleanly. Finalize (persist the GUID) only when - * processing completed cleanly AND any reply dispatch reported success; - * release (let a later replay try again) when processing threw OR the reply - * pipeline reported a delivery failure via its onError callback. - * - * The dedupe key follows the same canonicalization rules as the debouncer - * (`monitor-debounce.ts`): balloon events (URL previews, stickers) share - * a logical identity with their originating text message via - * `associatedMessageGuid`, so balloon-first vs text-first event ordering - * cannot produce two distinct dedupe keys for the same logical message. - */ -export async function processMessage( - message: NormalizedWebhookMessage, - target: WebhookTarget, -): Promise { - const { account, core, runtime } = target; - - const dedupeKey = resolveBlueBubblesInboundDedupeKey(message); - - // Drop BlueBubbles MessagePoller replays after server restart (#19176, #12053). - const claim = await claimBlueBubblesInboundMessage({ - guid: dedupeKey, - accountId: account.accountId, - onDiskError: (error) => - logVerbose(core, runtime, `inbound-dedupe disk error: ${sanitizeForLog(error)}`), - }); - if (claim.kind === "duplicate" || claim.kind === "inflight") { - logVerbose( - core, - runtime, - `drop: ${claim.kind} inbound key=${sanitizeForLog(dedupeKey ?? "")} sender=${sanitizeForLog(message.senderId)}`, - ); - return; - } - - const signal: InboundDedupeDeliverySignal = { deliveryFailed: false }; - try { - await processMessageAfterDedupe(message, target, signal); - } catch (error) { - if (claim.kind === "claimed") { - claim.release(); - } - throw error; - } - if (claim.kind === "claimed") { - if (signal.deliveryFailed) { - logVerbose( - core, - runtime, - `inbound-dedupe: releasing claim for key=${sanitizeForLog(dedupeKey ?? "")} after reply delivery failure (will retry on replay)`, - ); - claim.release(); - } else { - try { - await claim.finalize(); - } catch (finalizeError) { - // commit() already clears inflight state in its finally block, so - // no explicit release() needed here — just log the persistence error. - logVerbose( - core, - runtime, - `inbound-dedupe: finalize failed for key=${sanitizeForLog(dedupeKey ?? "")}: ${sanitizeForLog(finalizeError)}`, - ); - } - // When the debouncer coalesced multiple source webhook events into this - // single processed message, every source messageId must reach dedupe so - // a later MessagePoller replay of any individual source event is - // recognized as a duplicate. The primary is already finalized above; - // commit the rest here (best-effort, per-id). - const secondaryIds = (message.coalescedMessageIds ?? []).filter((id) => id !== dedupeKey); - if (secondaryIds.length > 0) { - try { - await commitBlueBubblesCoalescedMessageIds({ - messageIds: secondaryIds, - accountId: account.accountId, - onDiskError: (error) => - logVerbose( - core, - runtime, - `inbound-dedupe: coalesced secondary commit disk error: ${sanitizeForLog(error)}`, - ), - }); - } catch (secondaryError) { - logVerbose( - core, - runtime, - `inbound-dedupe: coalesced secondary commit failed for primary=${sanitizeForLog(dedupeKey ?? "")}: ${sanitizeForLog(secondaryError)}`, - ); - } - } - } - } -} - -async function processMessageAfterDedupe( - message: NormalizedWebhookMessage, - target: WebhookTarget, - dedupeSignal: InboundDedupeDeliverySignal, -): Promise { - const { account, config, runtime, core, statusSink } = target; - - const pairing = createChannelPairingController({ - core, - channel: "bluebubbles", - accountId: account.accountId, - }); - const privateApiEnabled = isBlueBubblesPrivateApiEnabled(account.accountId); - - const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid); - const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup; - - const text = message.text.trim(); - let attachments = message.attachments ?? []; - const baseUrl = normalizeSecretInputString(account.config.serverUrl); - const password = normalizeSecretInputString(account.config.password); - - // BlueBubbles may fire the webhook before attachment indexing is complete, - // so the initial `attachments` array can be empty for messages that actually - // have media. When the message text is empty (image-only) or this is an - // `updated-message` event, wait briefly and re-fetch from the BB API as a - // fallback for cases where BB doesn't send a follow-up webhook. (#65430, #67437) - // This must run before the !rawBody guard below, otherwise image-only messages - // with empty attachments are dropped before the retry can fire. - const retryMessageId = message.messageId?.trim(); - const shouldRetryAttachments = - attachments.length === 0 && - retryMessageId && - baseUrl && - password && - (text.length === 0 || message.eventType === "updated-message"); - if (shouldRetryAttachments) { - try { - await new Promise((resolve) => setTimeout(resolve, 2_000)); - const fetched = await fetchBlueBubblesMessageAttachments(retryMessageId, { - baseUrl, - password, - timeoutMs: 10_000, - allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config), - }); - if (fetched.length > 0) { - logVerbose( - core, - runtime, - `attachment retry found ${fetched.length} attachment(s) for msgId=${message.messageId}`, - ); - attachments = fetched; - } - } catch (err) { - logVerbose( - core, - runtime, - `attachment retry failed for msgId=${sanitizeForLog(message.messageId)}: ${sanitizeForLog(err)}`, - ); - } - } - - // Recompute placeholder from resolved attachments (may have been updated by retry). - const placeholder = buildMessagePlaceholder({ ...message, attachments }); - // 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 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" - ? `removed ${tapbackParsed.emoji} reaction` - : `reacted with ${tapbackParsed.emoji}` - : text || placeholder; - const isSelfChatMessage = isBlueBubblesSelfChatMessage(message, isGroup); - const selfChatLookup = { - accountId: account.accountId, - chatGuid: message.chatGuid, - chatIdentifier: message.chatIdentifier, - chatId: message.chatId, - senderId: message.senderId, - body: rawBody, - timestamp: message.timestamp, - }; - - const cacheMessageId = message.messageId?.trim(); - const confirmedOutboundCacheEntry = cacheMessageId - ? resolveReplyContextFromCache({ - accountId: account.accountId, - replyToId: cacheMessageId, - chatGuid: message.chatGuid, - chatIdentifier: message.chatIdentifier, - chatId: message.chatId, - }) - : null; - let messageShortId: string | undefined; - const cacheInboundMessage = () => { - if (!cacheMessageId) { - return; - } - const cacheEntry = rememberBlueBubblesReplyCache({ - accountId: account.accountId, - messageId: cacheMessageId, - chatGuid: message.chatGuid, - chatIdentifier: message.chatIdentifier, - chatId: message.chatId, - senderLabel: message.fromMe ? "me" : message.senderId, - body: rawBody, - timestamp: message.timestamp ?? Date.now(), - }); - messageShortId = cacheEntry.shortId; - }; - - if (message.fromMe) { - // Cache from-me messages so reply context can resolve sender/body. - cacheInboundMessage(); - const confirmedAssistantOutbound = - confirmedOutboundCacheEntry?.senderLabel === "me" && - normalizeSnippet(confirmedOutboundCacheEntry.body ?? "") === normalizeSnippet(rawBody); - if (isSelfChatMessage && confirmedAssistantOutbound) { - rememberBlueBubblesSelfChatCopy(selfChatLookup); - } - if (cacheMessageId) { - const pending = consumePendingOutboundMessageId({ - accountId: account.accountId, - chatGuid: message.chatGuid, - chatIdentifier: message.chatIdentifier, - chatId: message.chatId, - body: rawBody, - }); - if (pending) { - const displayId = getShortIdForUuid(cacheMessageId) || cacheMessageId; - const previewSource = pending.snippetRaw || rawBody; - const preview = previewSource - ? ` "${previewSource.slice(0, 12)}${previewSource.length > 12 ? "…" : ""}"` - : ""; - core.system.enqueueSystemEvent(`Assistant sent${preview} [message_id:${displayId}]`, { - sessionKey: pending.sessionKey, - contextKey: `bluebubbles:outbound:${pending.outboundTarget}:${cacheMessageId}`, - }); - } - } - return; - } - - if (isSelfChatMessage && hasBlueBubblesSelfChatCopy(selfChatLookup)) { - logVerbose( - core, - runtime, - `drop: reflected self-chat duplicate sender=${sanitizeForLog(message.senderId)}`, - ); - return; - } - - if (!rawBody) { - logVerbose(core, runtime, `drop: empty text sender=${sanitizeForLog(message.senderId)}`); - return; - } - logVerbose( - core, - runtime, - `msg sender=${sanitizeForLog(message.senderId)} group=${isGroup} textLen=${text.length} attachments=${attachments.length} chatGuid=${sanitizeForLog(message.chatGuid ?? "")} chatId=${sanitizeForLog(message.chatId ?? "")}`, - ); - - const dmPolicy = account.config.dmPolicy ?? "pairing"; - const groupPolicy = account.config.groupPolicy ?? "allowlist"; - const configuredAllowFrom = mapAllowFromEntries(account.config.allowFrom); - const storeAllowFrom = await readStoreAllowFromForDmPolicy({ - provider: "bluebubbles", - accountId: account.accountId, - dmPolicy, - readStore: pairing.readStoreForDmPolicy, - }); - const accessDecision = resolveDmGroupAccessWithLists({ - isGroup, - dmPolicy, - groupPolicy, - allowFrom: configuredAllowFrom, - groupAllowFrom: account.config.groupAllowFrom, - storeAllowFrom, - isSenderAllowed: (allowFrom) => - isAllowedBlueBubblesSender({ - allowFrom, - sender: message.senderId, - chatId: message.chatId ?? undefined, - chatGuid: message.chatGuid ?? undefined, - chatIdentifier: message.chatIdentifier ?? undefined, - }), - }); - const effectiveAllowFrom = accessDecision.effectiveAllowFrom; - const effectiveGroupAllowFrom = accessDecision.effectiveGroupAllowFrom; - const groupAllowEntry = formatGroupAllowlistEntry({ - chatGuid: message.chatGuid, - chatId: message.chatId ?? undefined, - chatIdentifier: message.chatIdentifier ?? undefined, - }); - const groupName = normalizeOptionalString(message.chatName); - - if (accessDecision.decision !== "allow") { - if (isGroup) { - if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED) { - logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)"); - logGroupAllowlistHint({ - runtime, - reason: "groupPolicy=disabled", - entry: groupAllowEntry, - chatName: groupName, - accountId: account.accountId, - }); - return; - } - if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST) { - logVerbose(core, runtime, "Blocked BlueBubbles group message (no allowlist)"); - logGroupAllowlistHint({ - runtime, - reason: "groupPolicy=allowlist (empty allowlist)", - entry: groupAllowEntry, - chatName: groupName, - accountId: account.accountId, - }); - return; - } - if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED) { - logVerbose( - core, - runtime, - `Blocked BlueBubbles sender ${message.senderId} (not in groupAllowFrom)`, - ); - logVerbose( - core, - runtime, - `drop: group sender not allowed sender=${message.senderId} allowFrom=${effectiveGroupAllowFrom.join(",")}`, - ); - logGroupAllowlistHint({ - runtime, - reason: "groupPolicy=allowlist (not allowlisted)", - entry: groupAllowEntry, - chatName: groupName, - accountId: account.accountId, - }); - return; - } - return; - } - - if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED) { - logVerbose(core, runtime, `Blocked BlueBubbles DM from ${message.senderId}`); - logVerbose(core, runtime, `drop: dmPolicy disabled sender=${message.senderId}`); - return; - } - - if (accessDecision.decision === "pairing") { - await pairing.issueChallenge({ - senderId: message.senderId, - senderIdLine: `Your BlueBubbles sender id: ${message.senderId}`, - meta: { name: message.senderName }, - onCreated: () => { - runtime.log?.( - `[bluebubbles] pairing request sender=${sanitizeForLog(message.senderId)} created=true`, - ); - logVerbose( - core, - runtime, - `bluebubbles pairing request sender=${sanitizeForLog(message.senderId)}`, - ); - }, - sendPairingReply: async (text) => { - await sendMessageBlueBubbles(message.senderId, text, { - cfg: config, - accountId: account.accountId, - }); - statusSink?.({ lastOutboundAt: Date.now() }); - }, - onReplyError: (err) => { - logVerbose( - core, - runtime, - `bluebubbles pairing reply failed for ${sanitizeForLog(message.senderId)}: ${sanitizeForLog(err)}`, - ); - runtime.error?.( - `[bluebubbles] pairing reply failed sender=${sanitizeForLog(message.senderId)}: ${sanitizeForLog(err)}`, - ); - }, - }); - return; - } - - logVerbose( - core, - runtime, - `Blocked unauthorized BlueBubbles sender ${message.senderId} (dmPolicy=${dmPolicy})`, - ); - logVerbose( - core, - runtime, - `drop: dm sender not allowed sender=${message.senderId} allowFrom=${effectiveAllowFrom.join(",")}`, - ); - return; - } - - const chatId = message.chatId ?? undefined; - const chatGuid = message.chatGuid ?? undefined; - const chatIdentifier = message.chatIdentifier ?? undefined; - const peerId = isGroup - ? (chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group")) - : message.senderId; - - const route = resolveBlueBubblesConversationRoute({ - cfg: config, - accountId: account.accountId, - isGroup, - peerId, - sender: message.senderId, - chatId, - chatGuid, - chatIdentifier, - }); - const contextVisibilityMode = resolveChannelContextVisibilityMode({ - cfg: config, - channel: "bluebubbles", - accountId: account.accountId, - }); - - // Mention gating for group chats (parity with iMessage/WhatsApp) - const messageText = text; - const mentionRegexes = core.channel.mentions.buildMentionRegexes(config, route.agentId); - const wasMentioned = isGroup - ? core.channel.mentions.matchesMentionPatterns(messageText, mentionRegexes) - : true; - const canDetectMention = mentionRegexes.length > 0; - const requireMention = core.channel.groups.resolveRequireMention({ - cfg: config, - channel: "bluebubbles", - groupId: peerId, - accountId: account.accountId, - }); - - // Command gating (parity with iMessage/WhatsApp) - const useAccessGroups = config.commands?.useAccessGroups !== false; - const hasControlCmd = core.channel.text.hasControlCommand(messageText, config); - const commandDmAllowFrom = isGroup ? configuredAllowFrom : effectiveAllowFrom; - const ownerAllowedForCommands = - commandDmAllowFrom.length > 0 - ? isAllowedBlueBubblesSender({ - allowFrom: commandDmAllowFrom, - sender: message.senderId, - chatId: message.chatId ?? undefined, - chatGuid: message.chatGuid ?? undefined, - chatIdentifier: message.chatIdentifier ?? undefined, - }) - : false; - const groupAllowedForCommands = - effectiveGroupAllowFrom.length > 0 - ? isAllowedBlueBubblesSender({ - allowFrom: effectiveGroupAllowFrom, - sender: message.senderId, - chatId: message.chatId ?? undefined, - chatGuid: message.chatGuid ?? undefined, - chatIdentifier: message.chatIdentifier ?? undefined, - }) - : false; - const commandGate = resolveControlCommandGate({ - useAccessGroups, - authorizers: [ - { configured: commandDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, - { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, - ], - allowTextCommands: true, - hasControlCommand: hasControlCmd, - }); - const commandAuthorized = commandGate.commandAuthorized; - - // Block control commands from unauthorized senders in groups - if (isGroup && commandGate.shouldBlock) { - logInboundDrop({ - log: (msg) => logVerbose(core, runtime, msg), - channel: "bluebubbles", - reason: "control command (unauthorized)", - target: message.senderId, - }); - return; - } - - // Allow control commands to bypass mention gating when authorized (parity with iMessage) - const shouldBypassMention = - isGroup && requireMention && !wasMentioned && commandAuthorized && hasControlCmd; - const effectiveWasMentioned = wasMentioned || shouldBypassMention; - - // Skip group messages that require mention but weren't mentioned - if (isGroup && requireMention && canDetectMention && !wasMentioned && !shouldBypassMention) { - logVerbose(core, runtime, `bluebubbles: skipping group message (no mention)`); - return; - } - - if (isGroup && !message.participants?.length && baseUrl && password) { - try { - const fetchedParticipants = await fetchBlueBubblesParticipantsForInboundMessage({ - baseUrl, - password, - chatGuid: message.chatGuid, - chatId: message.chatId, - chatIdentifier: message.chatIdentifier, - allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config), - }); - if (fetchedParticipants?.length) { - message.participants = fetchedParticipants; - } - } catch (err) { - logVerbose( - core, - runtime, - `bluebubbles: participant fallback lookup failed chat=${sanitizeForLog(peerId)}: ${sanitizeForLog(err)}`, - ); - } - } - - if ( - isGroup && - account.config.enrichGroupParticipantsFromContacts === true && - message.participants?.length - ) { - // BlueBubbles only gives us participant handles, so enrich phone numbers from local Contacts - // after access, command, and mention gating have already allowed the message through. - message.participants = await enrichBlueBubblesParticipantsWithContactNames( - message.participants, - ); - } - - // Cache allowed inbound messages so later replies can resolve sender/body without - // surfacing dropped content (allowlist/mention/command gating). - cacheInboundMessage(); - - const maxBytes = - account.config.mediaMaxMb && account.config.mediaMaxMb > 0 - ? account.config.mediaMaxMb * 1024 * 1024 - : 8 * 1024 * 1024; - - let mediaUrls: string[] = []; - let mediaPaths: string[] = []; - let mediaTypes: string[] = []; - if (attachments.length > 0) { - if (!baseUrl || !password) { - logVerbose(core, runtime, "attachment download skipped (missing serverUrl/password)"); - } else { - for (const attachment of attachments) { - if (!attachment.guid) { - continue; - } - if (attachment.totalBytes && attachment.totalBytes > maxBytes) { - logVerbose( - core, - runtime, - `attachment too large guid=${attachment.guid} bytes=${attachment.totalBytes}`, - ); - continue; - } - try { - const downloaded = await downloadBlueBubblesAttachment(attachment, { - cfg: config, - accountId: account.accountId, - maxBytes, - }); - const saved = await core.channel.media.saveMediaBuffer( - Buffer.from(downloaded.buffer), - downloaded.contentType, - "inbound", - maxBytes, - ); - mediaPaths.push(saved.path); - mediaUrls.push(saved.path); - if (saved.contentType) { - mediaTypes.push(saved.contentType); - } - } catch (err) { - // Promote to runtime.error so silently-dropped inbound images are - // visible at default log level, while keeping verbose detail for - // debug sessions. Sanitize both fields — BB attachment GUIDs are - // user-influenced and the error chain can carry the password - // (see sanitizeForLog above). - const safeGuid = sanitizeForLog(attachment.guid, 80); - const safeErr = sanitizeForLog(err); - runtime.error?.( - `[bluebubbles] attachment download failed guid=${safeGuid} err=${safeErr}`, - ); - logVerbose(core, runtime, `attachment download failed guid=${safeGuid} err=${safeErr}`); - } - } - } - } - let replyToId = message.replyToId; - let replyToBody = message.replyToBody; - let replyToSender = message.replyToSender; - let replyToShortId: string | undefined; - - if (isTapbackMessage && tapbackContext?.replyToId) { - replyToId = tapbackContext.replyToId; - } - - if (replyToId) { - const cached = resolveReplyContextFromCache({ - accountId: account.accountId, - replyToId, - chatGuid: message.chatGuid, - chatIdentifier: message.chatIdentifier, - chatId: message.chatId, - }); - if (cached) { - if (!replyToBody && cached.body) { - replyToBody = cached.body; - } - if (!replyToSender && cached.senderLabel) { - replyToSender = cached.senderLabel; - } - replyToShortId = cached.shortId; - if (core.logging.shouldLogVerbose()) { - const preview = (cached.body ?? "").replace(/\s+/g, " ").slice(0, 120); - logVerbose( - core, - runtime, - `reply-context cache hit replyToId=${replyToId} sender=${replyToSender ?? ""} body="${preview}"`, - ); - } - } - } - - // Opt-in fallback: if the in-memory cache missed and the BB credentials are - // available, ask the BlueBubbles HTTP API for the original message. Useful - // when multiple OpenClaw instances share one BB account, after a restart, - // or when the cache TTL has evicted the message. Best-effort, never throws. - if ( - replyToId && - (!replyToBody || !replyToSender) && - baseUrl && - password && - account.config.replyContextApiFallback === true - ) { - const fetched = await fetchBlueBubblesReplyContext({ - accountId: account.accountId, - replyToId, - baseUrl, - password, - accountConfig: account.config, - chatGuid: message.chatGuid, - chatIdentifier: message.chatIdentifier, - chatId: message.chatId, - }); - if (fetched) { - if (!replyToBody && fetched.body) { - replyToBody = fetched.body; - } - if (!replyToSender && fetched.sender) { - replyToSender = fetched.sender; - } - if (core.logging.shouldLogVerbose()) { - // Run the body preview through sanitizeForLog so the redaction regex - // (?password=, ?token=, Authorization: …) catches credential-shaped - // strings that may appear in user message bodies, matching the - // hygiene of adjacent verbose log lines in this file. - const preview = sanitizeForLog((fetched.body ?? "").replace(/\s+/g, " "), 120); - logVerbose( - core, - runtime, - `reply-context API fallback replyToId=${sanitizeForLog(replyToId)} sender=${sanitizeForLog(fetched.sender ?? "")} body="${preview}"`, - ); - } - } - } - - // If no cached short ID, try to get one from the UUID directly - if (replyToId && !replyToShortId) { - replyToShortId = getShortIdForUuid(replyToId); - } - const hasReplyContext = Boolean(replyToId || replyToBody || replyToSender); - const replySenderAllowed = - !isGroup || effectiveGroupAllowFrom.length === 0 - ? true - : replyToSender - ? isAllowedBlueBubblesSender({ - allowFrom: effectiveGroupAllowFrom, - sender: replyToSender, - chatId: message.chatId ?? undefined, - chatGuid: message.chatGuid ?? undefined, - chatIdentifier: message.chatIdentifier ?? undefined, - }) - : false; - const includeReplyContext = - !hasReplyContext || - evaluateSupplementalContextVisibility({ - mode: contextVisibilityMode, - kind: "quote", - senderAllowed: replySenderAllowed, - }).include; - if (hasReplyContext && !includeReplyContext && isGroup) { - logVerbose( - core, - runtime, - `bluebubbles: drop reply context (mode=${contextVisibilityMode}, sender_allowed=${replySenderAllowed ? "yes" : "no"})`, - ); - } - const visibleReplyToId = includeReplyContext ? replyToId : undefined; - const visibleReplyToShortId = includeReplyContext ? replyToShortId : undefined; - const visibleReplyToBody = includeReplyContext ? replyToBody : undefined; - const visibleReplyToSender = includeReplyContext ? replyToSender : undefined; - - // Use inline [[reply_to:N]] tag format - // For tapbacks/reactions: append at end (e.g., "reacted with ❤️ [[reply_to:4]]") - // For regular replies: prepend at start (e.g., "[[reply_to:4]] Awesome") - const replyTag = formatReplyTag({ - replyToId: visibleReplyToId, - replyToShortId: visibleReplyToShortId, - }); - const baseBody = replyTag - ? isTapbackMessage - ? `${rawBody} ${replyTag}` - : `${replyTag} ${rawBody}` - : rawBody; - // Build fromLabel the same way as iMessage/Signal (formatInboundFromLabel): - // group label + id for groups, sender for DMs. - // The sender identity is included in the envelope body via formatInboundEnvelope. - const senderLabel = message.senderName || `user:${message.senderId}`; - const fromLabel = isGroup - ? `${normalizeOptionalString(message.chatName) || "Group"} id:${peerId}` - : senderLabel !== message.senderId - ? `${senderLabel} id:${message.senderId}` - : senderLabel; - const groupSubject = isGroup ? normalizeOptionalString(message.chatName) : undefined; - const groupMembers = isGroup - ? formatGroupMembers({ - participants: message.participants, - fallback: message.senderId ? { id: message.senderId, name: message.senderName } : undefined, - }) - : undefined; - const storePath = core.channel.session.resolveStorePath(config.session?.store, { - agentId: route.agentId, - }); - const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config); - const previousTimestamp = core.channel.session.readSessionUpdatedAt({ - storePath, - sessionKey: route.sessionKey, - }); - const body = core.channel.reply.formatInboundEnvelope({ - channel: "BlueBubbles", - from: fromLabel, - timestamp: message.timestamp, - previousTimestamp, - envelope: envelopeOptions, - body: baseBody, - chatType: isGroup ? "group" : "direct", - sender: { name: message.senderName || undefined, id: message.senderId }, - }); - let chatGuidForActions = chatGuid; - if (!chatGuidForActions && baseUrl && password) { - const resolveTarget = buildBlueBubblesInboundChatResolveTarget({ - isGroup, - chatId, - chatIdentifier, - senderId: message.senderId, - }); - if (resolveTarget) { - chatGuidForActions = - (await resolveChatGuidForTarget({ - baseUrl, - password, - target: resolveTarget, - allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config), - })) ?? undefined; - } else { - logVerbose( - core, - runtime, - `cannot resolve chatGuid for group inbound (chatGuid/chatId/chatIdentifier all missing); senderId=${sanitizeForLog(message.senderId)}`, - ); - } - } - - const ackReactionScope = config.messages?.ackReactionScope ?? "group-mentions"; - const removeAckAfterReply = config.messages?.removeAckAfterReply ?? false; - const ackReactionValue = resolveBlueBubblesAckReaction({ - cfg: config, - agentId: route.agentId, - core, - runtime, - }); - const shouldAckReaction = () => - Boolean( - ackReactionValue && - core.channel.reactions.shouldAckReaction({ - scope: ackReactionScope, - isDirect: !isGroup, - isGroup, - isMentionableGroup: isGroup, - requireMention, - canDetectMention, - effectiveWasMentioned, - shouldBypassMention, - }), - ); - const ackMessageId = message.messageId?.trim() || ""; - const ackReactionPromise = - shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue - ? sendBlueBubblesReaction({ - chatGuid: chatGuidForActions, - messageGuid: ackMessageId, - emoji: ackReactionValue, - opts: { cfg: config, accountId: account.accountId }, - }).then( - () => true, - (err) => { - logVerbose( - core, - runtime, - `ack reaction failed chatGuid=${sanitizeForLog(chatGuidForActions)} msg=${sanitizeForLog(ackMessageId)}: ${sanitizeForLog(err)}`, - ); - return false; - }, - ) - : null; - - // Respect sendReadReceipts config (parity with WhatsApp) - const sendReadReceipts = account.config.sendReadReceipts !== false; - if (chatGuidForActions && baseUrl && password && sendReadReceipts) { - try { - await markBlueBubblesChatRead(chatGuidForActions, { - cfg: config, - accountId: account.accountId, - }); - logVerbose(core, runtime, `marked read chatGuid=${sanitizeForLog(chatGuidForActions)}`); - } catch (err) { - runtime.error?.(`[bluebubbles] mark read failed: ${sanitizeForLog(err)}`); - } - } else if (!sendReadReceipts) { - logVerbose(core, runtime, "mark read skipped (sendReadReceipts=false)"); - } else { - logVerbose(core, runtime, "mark read skipped (missing chatGuid or credentials)"); - } - - const outboundTarget = isGroup - ? formatBlueBubblesChatTarget({ - chatId, - chatGuid: chatGuidForActions ?? chatGuid, - chatIdentifier, - }) || peerId - : chatGuidForActions - ? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions }) - : message.senderId; - - const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: string): boolean => { - const trimmed = messageId?.trim(); - if (!trimmed || trimmed === "ok" || trimmed === "unknown") { - return false; - } - // Cache outbound message to get short ID - const cacheEntry = rememberBlueBubblesReplyCache({ - accountId: account.accountId, - messageId: trimmed, - chatGuid: chatGuidForActions ?? chatGuid, - chatIdentifier, - chatId, - senderLabel: "me", - body: snippet ?? "", - timestamp: Date.now(), - }); - const displayId = cacheEntry.shortId || trimmed; - const preview = snippet ? ` "${snippet.slice(0, 12)}${snippet.length > 12 ? "…" : ""}"` : ""; - core.system.enqueueSystemEvent(`Assistant sent${preview} [message_id:${displayId}]`, { - sessionKey: route.sessionKey, - contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`, - }); - return true; - }; - const sanitizeReplyDirectiveText = (value: string): string => { - if (privateApiEnabled) { - return value; - } - return value - .replace(REPLY_DIRECTIVE_TAG_RE, " ") - .replace(/[ \t]+/g, " ") - .trim(); - }; - const resolveReplyToMessageGuidForPayload = (payload: { replyToId?: string | null }): string => { - const rawReplyToId = - privateApiEnabled && typeof payload.replyToId === "string" ? payload.replyToId.trim() : ""; - if (!rawReplyToId) { - return ""; - } - return ( - resolveBlueBubblesMessageId(rawReplyToId, { - requireKnownShortId: true, - chatContext: { - chatGuid: chatGuidForActions ?? chatGuid, - chatIdentifier, - chatId, - }, - }) || "" - ); - }; - const prepareBlueBubblesReplyPayload = (payload: ReplyPayload): ReplyPayload => { - const tableMode = core.channel.text.resolveMarkdownTableMode({ - cfg: config, - channel: "bluebubbles", - accountId: account.accountId, - }); - const text = sanitizeReplyDirectiveText( - core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode), - ); - return { - ...payload, - text, - ...(typeof payload.replyToId === "string" && !privateApiEnabled ? { replyToId: "" } : {}), - }; - }; - const canUseDurableBlueBubblesFinalDelivery = (payload: { text?: string }): boolean => { - const textLimit = - account.config.textChunkLimit && account.config.textChunkLimit > 0 - ? account.config.textChunkLimit - : DEFAULT_TEXT_LIMIT; - return (payload.text ?? "").length <= textLimit; - }; - - // History: in-memory rolling map with bounded API backfill retries - const historyLimit = isGroup - ? (account.config.historyLimit ?? 0) - : (account.config.dmHistoryLimit ?? 0); - - const historyIdentifier = - chatGuid || - chatIdentifier || - (chatId ? String(chatId) : null) || - (isGroup ? null : message.senderId) || - ""; - const historyKey = historyIdentifier - ? buildAccountScopedHistoryKey(account.accountId, historyIdentifier) - : ""; - - // Record the current message into rolling history - if (historyKey && historyLimit > 0) { - const nowMs = Date.now(); - const senderLabel = message.fromMe ? "me" : message.senderName || message.senderId; - const normalizedHistoryBody = truncateHistoryBody(text, MAX_STORED_HISTORY_ENTRY_CHARS); - const currentEntries = recordPendingHistoryEntryIfEnabled({ - historyMap: chatHistories, - limit: historyLimit, - historyKey, - entry: normalizedHistoryBody - ? { - sender: senderLabel, - body: normalizedHistoryBody, - timestamp: message.timestamp ?? nowMs, - messageId: message.messageId ?? undefined, - } - : null, - }); - pruneHistoryBackfillState(); - - const backfillAttempt = planHistoryBackfillAttempt(historyKey, nowMs); - if (backfillAttempt) { - try { - const backfillResult = await fetchBlueBubblesHistory(historyIdentifier, historyLimit, { - cfg: config, - accountId: account.accountId, - }); - if (backfillResult.resolved) { - markHistoryBackfillResolved(historyKey); - } - if (backfillResult.entries.length > 0) { - const apiEntries: HistoryEntry[] = []; - for (const entry of backfillResult.entries) { - const body = truncateHistoryBody(entry.body, MAX_STORED_HISTORY_ENTRY_CHARS); - if (!body) { - continue; - } - apiEntries.push({ - sender: entry.sender, - body, - timestamp: entry.timestamp, - messageId: entry.messageId, - }); - } - const merged = mergeHistoryEntries({ - apiEntries, - currentEntries: - currentEntries.length > 0 ? currentEntries : (chatHistories.get(historyKey) ?? []), - limit: historyLimit, - }); - if (chatHistories.has(historyKey)) { - chatHistories.delete(historyKey); - } - chatHistories.set(historyKey, merged); - evictOldHistoryKeys(chatHistories); - logVerbose( - core, - runtime, - `backfilled ${backfillResult.entries.length} history messages for ${isGroup ? "group" : "DM"}: ${historyIdentifier}`, - ); - } else if (!backfillResult.resolved) { - const remainingAttempts = HISTORY_BACKFILL_MAX_ATTEMPTS - backfillAttempt.attempts; - const nextBackoffMs = Math.max(backfillAttempt.nextAttemptAt - nowMs, 0); - logVerbose( - core, - runtime, - `history backfill unresolved for ${historyIdentifier}; retries left=${Math.max(remainingAttempts, 0)} next_in_ms=${nextBackoffMs}`, - ); - } - } catch (err) { - const remainingAttempts = HISTORY_BACKFILL_MAX_ATTEMPTS - backfillAttempt.attempts; - const nextBackoffMs = Math.max(backfillAttempt.nextAttemptAt - nowMs, 0); - logVerbose( - core, - runtime, - `history backfill failed for ${sanitizeForLog(historyIdentifier)}: ${sanitizeForLog(err)} (retries left=${Math.max(remainingAttempts, 0)} next_in_ms=${nextBackoffMs})`, - ); - } - } - } - - // Build inbound history from the in-memory map - let inboundHistory: Array<{ sender: string; body: string; timestamp?: number }> | undefined; - if (historyKey && historyLimit > 0) { - const entries = chatHistories.get(historyKey); - if (entries && entries.length > 0) { - inboundHistory = buildInboundHistorySnapshot({ - entries, - limit: historyLimit, - }); - } - } - const commandBody = messageText.trim(); - - const ctxPayload = core.channel.reply.finalizeInboundContext({ - Body: body, - BodyForAgent: rawBody, - InboundHistory: inboundHistory, - RawBody: rawBody, - CommandBody: commandBody, - BodyForCommands: commandBody, - MediaUrl: mediaUrls[0], - MediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined, - MediaPath: mediaPaths[0], - MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined, - MediaType: mediaTypes[0], - MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined, - From: isGroup ? `group:${peerId}` : `bluebubbles:${message.senderId}`, - To: `bluebubbles:${outboundTarget}`, - SessionKey: route.sessionKey, - AccountId: route.accountId, - ChatType: isGroup ? "group" : "direct", - ConversationLabel: fromLabel, - // Use short ID for token savings (agent can use this to reference the message) - ReplyToId: visibleReplyToShortId || visibleReplyToId, - ReplyToIdFull: visibleReplyToId, - ReplyToBody: visibleReplyToBody, - ReplyToSender: visibleReplyToSender, - GroupSubject: groupSubject, - GroupMembers: groupMembers, - SenderName: message.senderName || undefined, - SenderId: message.senderId, - Provider: "bluebubbles", - Surface: "bluebubbles", - // Use short ID for token savings (agent can use this to reference the message) - MessageSid: messageShortId || message.messageId, - MessageSidFull: message.messageId, - Timestamp: message.timestamp, - OriginatingChannel: "bluebubbles", - OriginatingTo: `bluebubbles:${outboundTarget}`, - WasMentioned: effectiveWasMentioned, - CommandAuthorized: commandAuthorized, - // Exact group match wins over the "*" wildcard fallback, matching the - // pattern used by resolveChannelGroupRequireMention/toolsPolicy. - GroupSystemPrompt: isGroup - ? normalizeOptionalString( - account.config.groups?.[peerId]?.systemPrompt ?? - account.config.groups?.["*"]?.systemPrompt, - ) - : undefined, - }); - - let sentMessage = false; - let streamingActive = false; - let typingRestartTimer: NodeJS.Timeout | undefined; - const typingRestartDelayMs = 150; - const clearTypingRestartTimer = () => { - if (typingRestartTimer) { - clearTimeout(typingRestartTimer); - typingRestartTimer = undefined; - } - }; - const restartTypingSoon = () => { - if (!streamingActive || !chatGuidForActions || !baseUrl || !password) { - return; - } - clearTypingRestartTimer(); - typingRestartTimer = setTimeout(() => { - typingRestartTimer = undefined; - if (!streamingActive) { - return; - } - sendBlueBubblesTyping(chatGuidForActions, true, { - cfg: config, - accountId: account.accountId, - }).catch((err) => { - runtime.error?.(`[bluebubbles] typing restart failed: ${sanitizeForLog(err)}`); - }); - }, typingRestartDelayMs); - }; - try { - const typingCallbacks = { - onReplyStart: async () => { - if (!chatGuidForActions) { - return; - } - if (!baseUrl || !password) { - return; - } - streamingActive = true; - clearTypingRestartTimer(); - try { - await sendBlueBubblesTyping(chatGuidForActions, true, { - cfg: config, - accountId: account.accountId, - }); - } catch (err) { - runtime.error?.(`[bluebubbles] typing start failed: ${sanitizeForLog(err)}`); - } - }, - onIdle: () => { - if (!chatGuidForActions) { - return; - } - if (!baseUrl || !password) { - return; - } - // Intentionally no-op for block streaming. We stop typing in finally - // after the run completes to avoid flicker between paragraph blocks. - }, - }; - await core.channel.turn.run({ - channel: "bluebubbles", - accountId: account.accountId, - raw: ctxPayload, - adapter: { - ingest: () => ({ - id: String(ctxPayload.MessageSid ?? message.messageId), - timestamp: message.timestamp, - rawText: rawBody, - textForAgent: rawBody, - textForCommands: commandBody, - raw: ctxPayload, - }), - resolveTurn: () => ({ - cfg: config, - channel: "bluebubbles", - accountId: account.accountId, - agentId: route.agentId, - routeSessionKey: route.sessionKey, - storePath, - ctxPayload, - recordInboundSession: core.channel.session.recordInboundSession, - dispatchReplyWithBufferedBlockDispatcher: - core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, - delivery: { - preparePayload: (payload) => prepareBlueBubblesReplyPayload(payload), - durable: (payload, info) => { - if (info.kind !== "final" || !canUseDurableBlueBubblesFinalDelivery(payload)) { - return false; - } - const replyToMessageGuid = resolveReplyToMessageGuidForPayload(payload); - return { - to: outboundTarget, - replyToId: - typeof payload.replyToId === "string" ? payload.replyToId.trim() || null : null, - deps: { - bluebubblesMessageLifecycle: { - beforeSendAttempt: (ctx: { kind: string; text?: string }) => { - const snippet = - ctx.kind === "media" - ? (ctx.text ?? "").trim() || "" - : (ctx.text ?? "").trim(); - return rememberPendingOutboundMessageId({ - accountId: account.accountId, - sessionKey: route.sessionKey, - outboundTarget, - chatGuid: chatGuidForActions ?? chatGuid, - chatIdentifier, - chatId, - snippet, - }); - }, - afterSendSuccess: (ctx: { - kind: string; - text?: string; - result?: { messageId?: string }; - attemptToken?: unknown; - }) => { - const snippet = - ctx.kind === "media" - ? (ctx.text ?? "").trim() || "" - : (ctx.text ?? "").trim(); - if ( - maybeEnqueueOutboundMessageId(ctx.result?.messageId, snippet) && - typeof ctx.attemptToken === "number" - ) { - forgetPendingOutboundMessageId(ctx.attemptToken); - } - }, - afterSendFailure: (ctx: { attemptToken?: unknown }) => { - if (typeof ctx.attemptToken === "number") { - forgetPendingOutboundMessageId(ctx.attemptToken); - } - }, - }, - }, - requiredCapabilities: deriveDurableFinalDeliveryRequirements({ - payload, - replyToId: replyToMessageGuid || null, - afterSendSuccess: true, - }), - }; - }, - onDelivered: (_payload, info, result) => { - if (!result?.deliveryIntent) { - return; - } - if (result.visibleReplySent === true) { - sentMessage = true; - statusSink?.({ lastOutboundAt: Date.now() }); - if (info.kind === "block") { - restartTypingSoon(); - } - } - }, - deliver: async (payload, info) => { - const rawReplyToId = - privateApiEnabled && typeof payload.replyToId === "string" - ? payload.replyToId.trim() - : ""; - // Resolve short ID (e.g., "5") to full UUID, scoped to the chat - // this deliver path is already routing for (cross-chat guard). - const replyToMessageGuid = rawReplyToId - ? resolveBlueBubblesMessageId(rawReplyToId, { - requireKnownShortId: true, - chatContext: { - chatGuid: chatGuidForActions ?? chatGuid, - chatIdentifier, - chatId, - }, - }) - : ""; - const mediaList = resolveOutboundMediaUrls(payload); - if (mediaList.length > 0) { - const tableMode = core.channel.text.resolveMarkdownTableMode({ - cfg: config, - channel: "bluebubbles", - accountId: account.accountId, - }); - const text = sanitizeReplyDirectiveText( - core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode), - ); - await sendMediaWithLeadingCaption({ - mediaUrls: mediaList, - caption: text, - send: async ({ mediaUrl, caption }) => { - const cachedBody = (caption ?? "").trim() || ""; - const pendingId = rememberPendingOutboundMessageId({ - accountId: account.accountId, - sessionKey: route.sessionKey, - outboundTarget, - chatGuid: chatGuidForActions ?? chatGuid, - chatIdentifier, - chatId, - snippet: cachedBody, - }); - let result: Awaited>; - try { - result = await sendBlueBubblesMedia({ - cfg: config, - to: outboundTarget, - mediaUrl, - caption: caption ?? undefined, - replyToId: replyToMessageGuid || null, - accountId: account.accountId, - asVoice: payload.audioAsVoice === true, - }); - } catch (err) { - forgetPendingOutboundMessageId(pendingId); - throw err; - } - if (maybeEnqueueOutboundMessageId(result.messageId, cachedBody)) { - forgetPendingOutboundMessageId(pendingId); - } - sentMessage = true; - statusSink?.({ lastOutboundAt: Date.now() }); - if (info.kind === "block") { - restartTypingSoon(); - } - }, - }); - return; - } - - const textLimit = - account.config.textChunkLimit && account.config.textChunkLimit > 0 - ? account.config.textChunkLimit - : DEFAULT_TEXT_LIMIT; - const chunkMode = account.config.chunkMode ?? "length"; - const tableMode = core.channel.text.resolveMarkdownTableMode({ - cfg: config, - channel: "bluebubbles", - accountId: account.accountId, - }); - const text = sanitizeReplyDirectiveText( - core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode), - ); - const chunks = - chunkMode === "newline" - ? resolveTextChunksWithFallback( - text, - core.channel.text.chunkTextWithMode(text, textLimit, chunkMode), - ) - : resolveTextChunksWithFallback( - text, - core.channel.text.chunkMarkdownText(text, textLimit), - ); - if (!chunks.length) { - return; - } - for (const chunk of chunks) { - const pendingId = rememberPendingOutboundMessageId({ - accountId: account.accountId, - sessionKey: route.sessionKey, - outboundTarget, - chatGuid: chatGuidForActions ?? chatGuid, - chatIdentifier, - chatId, - snippet: chunk, - }); - let result: Awaited>; - try { - result = await sendMessageBlueBubbles(outboundTarget, chunk, { - cfg: config, - accountId: account.accountId, - replyToMessageGuid: replyToMessageGuid || undefined, - }); - } catch (err) { - forgetPendingOutboundMessageId(pendingId); - throw err; - } - if (maybeEnqueueOutboundMessageId(result.messageId, chunk)) { - forgetPendingOutboundMessageId(pendingId); - } - sentMessage = true; - statusSink?.({ lastOutboundAt: Date.now() }); - if (info.kind === "block") { - restartTypingSoon(); - } - } - }, - onError: (err, info) => { - // Flag the outer dedupe wrapper so it releases the claim instead - // of committing. Without this, a transient BlueBubbles send failure - // would permanently block replay-retry for 7 days and the user - // would never receive a reply to that message. - // - // Only the terminal `final` delivery represents the user-visible - // answer. The dispatcher continues past `tool` / `block` failures - // and may still deliver `final` successfully — releasing the - // dedupe claim for those would invite a replay that re-runs tool - // side effects and resends partially-delivered content. - if (info.kind === "final") { - dedupeSignal.deliveryFailed = true; - } - runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${sanitizeForLog(err)}`); - }, - }, - replyPipeline: { - typingCallbacks, - }, - dispatcherOptions: { - onReplyStart: typingCallbacks.onReplyStart, - onIdle: typingCallbacks.onIdle, - }, - replyOptions: { - disableBlockStreaming: - typeof account.config.blockStreaming === "boolean" - ? !account.config.blockStreaming - : undefined, - }, - record: { - onRecordError: (err) => { - runtime.error?.(`[bluebubbles] failed updating session meta: ${sanitizeForLog(err)}`); - }, - }, - }), - }, - }); - } finally { - const shouldStopTyping = - Boolean(chatGuidForActions && baseUrl && password) && (streamingActive || !sentMessage); - streamingActive = false; - clearTypingRestartTimer(); - if (sentMessage && chatGuidForActions && ackMessageId) { - core.channel.reactions.removeAckReactionAfterReply({ - removeAfterReply: removeAckAfterReply, - ackReactionPromise, - ackReactionValue: ackReactionValue ?? null, - remove: () => - sendBlueBubblesReaction({ - chatGuid: chatGuidForActions, - messageGuid: ackMessageId, - emoji: ackReactionValue ?? "", - remove: true, - opts: { cfg: config, accountId: account.accountId }, - }), - onError: (err) => { - logAckFailure({ - log: (msg) => logVerbose(core, runtime, msg), - channel: "bluebubbles", - target: `${chatGuidForActions}/${ackMessageId}`, - error: err, - }); - }, - }); - } - if (shouldStopTyping && chatGuidForActions) { - // Stop typing after streaming completes to avoid a stuck indicator. - sendBlueBubblesTyping(chatGuidForActions, false, { - cfg: config, - accountId: account.accountId, - }).catch((err) => { - logTypingFailure({ - log: (msg) => logVerbose(core, runtime, msg), - channel: "bluebubbles", - action: "stop", - target: chatGuidForActions, - error: err, - }); - }); - } - } -} - -export async function processReaction( - reaction: NormalizedWebhookReaction, - target: WebhookTarget, -): Promise { - const { account, config, runtime, core } = target; - const pairing = createChannelPairingController({ - core, - channel: "bluebubbles", - accountId: account.accountId, - }); - if (reaction.fromMe) { - return; - } - - // Group reaction with no chat identifiers cannot be routed safely. The - // peerId fallback below would degrade to the literal string "group", and - // resolveBlueBubblesConversationRoute would then synthesize a session key - // unrelated to any real binding — worse, an isGroup=false misclassification - // upstream would have routed this to the sender's DM session, surfacing - // a group tapback inside an unrelated 1:1 transcript. Drop+log instead. - // Treat whitespace-only chatGuid/chatIdentifier as missing — a webhook - // sender that supplies " " or "\t" must not be able to satisfy the guard - // and have peerId degrade to the literal "group" anyway. - const trimmedReactionChatGuid = reaction.chatGuid?.trim(); - const trimmedReactionChatIdentifier = reaction.chatIdentifier?.trim(); - if ( - reaction.isGroup && - !trimmedReactionChatGuid && - reaction.chatId == null && - !trimmedReactionChatIdentifier - ) { - logVerbose( - core, - runtime, - `dropping group reaction with no chat identifiers (senderId=${sanitizeForLog(reaction.senderId)} messageId=${sanitizeForLog(reaction.messageId)} action=${sanitizeForLog(reaction.action)})`, - ); - return; - } - - const dmPolicy = account.config.dmPolicy ?? "pairing"; - const groupPolicy = account.config.groupPolicy ?? "allowlist"; - const storeAllowFrom = await readStoreAllowFromForDmPolicy({ - provider: "bluebubbles", - accountId: account.accountId, - dmPolicy, - readStore: pairing.readStoreForDmPolicy, - }); - const accessDecision = resolveDmGroupAccessWithLists({ - isGroup: reaction.isGroup, - dmPolicy, - groupPolicy, - allowFrom: account.config.allowFrom, - groupAllowFrom: account.config.groupAllowFrom, - storeAllowFrom, - isSenderAllowed: (allowFrom) => - isAllowedBlueBubblesSender({ - allowFrom, - sender: reaction.senderId, - chatId: reaction.chatId ?? undefined, - chatGuid: reaction.chatGuid ?? undefined, - chatIdentifier: reaction.chatIdentifier ?? undefined, - }), - }); - if (accessDecision.decision !== "allow") { - return; - } - - const chatId = reaction.chatId ?? undefined; - const chatGuid = reaction.chatGuid ?? undefined; - const chatIdentifier = reaction.chatIdentifier ?? undefined; - const peerId = reaction.isGroup - ? (chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group")) - : reaction.senderId; - const requireMention = - reaction.isGroup && - core.channel.groups.resolveRequireMention({ - cfg: config, - channel: "bluebubbles", - groupId: peerId, - accountId: account.accountId, - }); - - if (requireMention) { - logVerbose(core, runtime, "bluebubbles: skipping group reaction (requireMention=true)"); - return; - } - - const route = resolveBlueBubblesConversationRoute({ - cfg: config, - accountId: account.accountId, - isGroup: reaction.isGroup, - peerId, - sender: reaction.senderId, - chatId, - chatGuid, - chatIdentifier, - }); - - const senderLabel = reaction.senderName || reaction.senderId; - const chatLabel = reaction.isGroup ? ` in group:${peerId}` : ""; - // Use short ID for token savings - const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId; - // Format: "Tyler reacted with ❤️ [[reply_to:5]]" or "Tyler removed ❤️ reaction [[reply_to:5]]" - const text = - reaction.action === "removed" - ? `${senderLabel} removed ${reaction.emoji} reaction [[reply_to:${messageDisplayId}]]${chatLabel}` - : `${senderLabel} reacted with ${reaction.emoji} [[reply_to:${messageDisplayId}]]${chatLabel}`; - core.system.enqueueSystemEvent(text, { - sessionKey: route.sessionKey, - contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`, - }); - logVerbose(core, runtime, `reaction event enqueued: ${text}`); -} diff --git a/extensions/bluebubbles/src/monitor-reply-cache.test.ts b/extensions/bluebubbles/src/monitor-reply-cache.test.ts deleted file mode 100644 index 225691dd173d..000000000000 --- a/extensions/bluebubbles/src/monitor-reply-cache.test.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - _resetBlueBubblesShortIdState, - rememberBlueBubblesReplyCache, - resolveBlueBubblesMessageId, -} from "./monitor-reply-cache.js"; -import { buildBlueBubblesChatContextFromTarget } from "./targets.js"; - -describe("resolveBlueBubblesMessageId chat-scoped short-id guard", () => { - beforeEach(() => { - _resetBlueBubblesShortIdState(); - }); - - afterEach(() => { - _resetBlueBubblesShortIdState(); - }); - - function seedMessage(args: { - accountId: string; - messageId: string; - chatGuid?: string; - chatIdentifier?: string; - chatId?: number; - }) { - return rememberBlueBubblesReplyCache({ - accountId: args.accountId, - messageId: args.messageId, - chatGuid: args.chatGuid, - chatIdentifier: args.chatIdentifier, - chatId: args.chatId, - timestamp: Date.now(), - }); - } - - it("returns the cached uuid when the short id resolves within the same chatGuid", () => { - const entry = seedMessage({ - accountId: "default", - messageId: "uuid-in-group", - chatGuid: "iMessage;+;chat240698944142298252", - }); - - const resolved = resolveBlueBubblesMessageId(entry.shortId, { - requireKnownShortId: true, - chatContext: { chatGuid: "iMessage;+;chat240698944142298252" }, - }); - - expect(resolved).toBe("uuid-in-group"); - }); - - it("throws when a short id points at a message in a different chatGuid", () => { - const groupEntry = seedMessage({ - accountId: "default", - messageId: "uuid-in-group", - chatGuid: "iMessage;+;chat240698944142298252", - }); - - // Agent tries to react in a DM but passes a short id that was allocated - // for a group message. Should throw instead of silently letting BB - // server route the tapback to the group (or worse, to an old DM that - // happens to share the short id slot). - expect(() => - resolveBlueBubblesMessageId(groupEntry.shortId, { - requireKnownShortId: true, - chatContext: { chatGuid: "iMessage;-;+8618621181874" }, - }), - ).toThrow(/different chat/); - }); - - it("rejects empty chat context for privileged callers (fail-closed cross-chat scope)", () => { - seedMessage({ - accountId: "default", - messageId: "uuid-no-ctx", - chatGuid: "iMessage;+;chat240698944142298252", - }); - - // Empty context = caller could not derive any chat hint. The previous - // behavior (fail-open) let a short id resolve without a chat scope — - // but short ids are global across all chats, so an action call without - // chat context could silently apply to the wrong conversation. Now - // requireKnownShortId callers must pass at least one identifier - // (chatGuid / chatIdentifier / chatId). - expect(() => - resolveBlueBubblesMessageId("1", { - requireKnownShortId: true, - chatContext: {}, - }), - ).toThrow(/requires a chat scope/); - }); - - it("falls back to chatIdentifier comparison when the caller has no chatGuid", () => { - const dmEntry = seedMessage({ - accountId: "default", - messageId: "uuid-dm-1", - chatIdentifier: "+8618621181874", - }); - - expect( - resolveBlueBubblesMessageId(dmEntry.shortId, { - requireKnownShortId: true, - chatContext: { chatIdentifier: "+8618621181874" }, - }), - ).toBe("uuid-dm-1"); - - expect(() => - resolveBlueBubblesMessageId(dmEntry.shortId, { - requireKnownShortId: true, - chatContext: { chatIdentifier: "+8618621185125" }, - }), - ).toThrow(/different chat/); - }); - - it("catches a handle-only caller against a cached entry that carries chatGuid", () => { - // Real-world failure mode: inbound webhooks populate cached entries with - // chatGuid (group or DM). A caller that only resolved a handle supplies - // ctx.chatIdentifier without ctx.chatGuid. The guard must still catch - // the mismatch so a group short-id cannot slip through when the call is - // for a DM, which is exactly how group reactions were leaking into DMs. - const groupEntry = seedMessage({ - accountId: "default", - messageId: "uuid-in-group", - chatGuid: "iMessage;+;chat240698944142298252", - chatIdentifier: "chat240698944142298252", - }); - - expect(() => - resolveBlueBubblesMessageId(groupEntry.shortId, { - requireKnownShortId: true, - chatContext: { chatIdentifier: "+8618621181874" }, - }), - ).toThrow(/different chat/); - }); - - it("falls back to chatId comparison when neither chatGuid nor chatIdentifier is available", () => { - const entry = seedMessage({ - accountId: "default", - messageId: "uuid-with-id", - chatId: 42, - }); - - expect( - resolveBlueBubblesMessageId(entry.shortId, { - requireKnownShortId: true, - chatContext: { chatId: 42 }, - }), - ).toBe("uuid-with-id"); - - expect(() => - resolveBlueBubblesMessageId(entry.shortId, { - requireKnownShortId: true, - chatContext: { chatId: 99 }, - }), - ).toThrow(/different chat/); - }); - - it("passes a full uuid through unchanged when not in the reply cache", () => { - // Cache miss falls through. Callers supplying a GUID that the cache - // hasn't observed get the input back so fresh-from-the-wire GUIDs - // (e.g. from a `find` API call) still work. - const resolved = resolveBlueBubblesMessageId("1E7E6B6A-0000-4C6C-BCA7-000000000001", { - requireKnownShortId: true, - chatContext: { chatGuid: "iMessage;+;anything" }, - }); - expect(resolved).toBe("1E7E6B6A-0000-4C6C-BCA7-000000000001"); - }); - - it("passes a full uuid through unchanged when caller supplies no chat context", () => { - // Belt-and-braces: even when the cache knows the GUID, callers that - // can't supply any chat hint at all (legacy tool invocations) fall - // through to preserve prior behavior. - seedMessage({ - accountId: "default", - messageId: "uuid-known", - chatGuid: "iMessage;+;chat240698944142298252", - }); - expect(resolveBlueBubblesMessageId("uuid-known")).toBe("uuid-known"); - expect(resolveBlueBubblesMessageId("uuid-known", { chatContext: {} })).toBe("uuid-known"); - }); - - it("accepts a full uuid that points at a same-chat cached entry", () => { - seedMessage({ - accountId: "default", - messageId: "uuid-in-group", - chatGuid: "iMessage;+;chat240698944142298252", - }); - - const resolved = resolveBlueBubblesMessageId("uuid-in-group", { - chatContext: { chatGuid: "iMessage;+;chat240698944142298252" }, - }); - expect(resolved).toBe("uuid-in-group"); - }); - - it("REJECTS a full uuid that points at a different chat in the cache", () => { - // Candidate-1 regression: the previous implementation only ran the - // cross-chat guard on numeric short ids. After the short-id guard - // landed, agents that retried with a full GUID (because the short id - // got rejected) silently bypassed the check. Group GUIDs reused in - // DM tool calls again leaked group reactions into DMs. - seedMessage({ - accountId: "default", - messageId: "uuid-in-group", - chatGuid: "iMessage;+;chat240698944142298252", - }); - - expect(() => - resolveBlueBubblesMessageId("uuid-in-group", { - chatContext: { chatGuid: "iMessage;-;+8618621181874" }, - }), - ).toThrow(/different chat/); - }); - - it("uuid-path error message hints at fixing the chat target, not the id format", () => { - // The short-id error tells the agent to retry with the full GUID. - // For UUID input that's already failed, advising "use the full GUID" - // would be wrong — the agent already supplied one. Make the - // remediation hint differ so a retrying agent is steered toward - // fixing the chat target. - seedMessage({ - accountId: "default", - messageId: "uuid-in-group", - chatGuid: "iMessage;+;chat240698944142298252", - }); - - try { - resolveBlueBubblesMessageId("uuid-in-group", { - chatContext: { chatGuid: "iMessage;-;+8618621181874" }, - }); - expect.fail("expected cross-chat guard to throw"); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - // Chat identifiers redacted in error message (PII / log-stream hardening). - expect(message).toContain("chatGuid="); - expect(message).not.toContain("iMessage;+;chat240698944142298252"); - expect(message).not.toContain("iMessage;-;+8618621181874"); - expect(message).toContain("correct chat target"); - expect(message).not.toContain("Retry with the full message GUID"); - } - }); - - it("applies the chatIdentifier fallback to full uuid input as well", () => { - // Same handle-only-caller scenario as the short-id case: a tool - // invocation might only resolve the chatIdentifier (the bare handle). - // The guard must catch GUID reuse across mismatched chatIdentifiers - // even when the caller has no chatGuid hint. - seedMessage({ - accountId: "default", - messageId: "uuid-in-group", - chatGuid: "iMessage;+;chat240698944142298252", - chatIdentifier: "chat240698944142298252", - }); - - expect(() => - resolveBlueBubblesMessageId("uuid-in-group", { - chatContext: { chatIdentifier: "+8618621181874" }, - }), - ).toThrow(/different chat/); - }); - - it("reports the conflicting chats in the error message for debugability", () => { - const entry = seedMessage({ - accountId: "default", - messageId: "uuid-in-group", - chatGuid: "iMessage;+;chat240698944142298252", - }); - - try { - resolveBlueBubblesMessageId(entry.shortId, { - requireKnownShortId: true, - chatContext: { chatGuid: "iMessage;-;+8618621181874" }, - }); - expect.fail("expected cross-chat guard to throw"); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - // Chat identifiers redacted in error message (PII / log-stream hardening). - expect(message).toContain("chatGuid="); - expect(message).not.toContain("iMessage;+;chat240698944142298252"); - expect(message).not.toContain("iMessage;-;+8618621181874"); - expect(message).toContain("full message GUID"); - } - }); - - it("still throws requireKnownShortId for unknown numeric inputs", () => { - expect(() => - resolveBlueBubblesMessageId("999", { - requireKnownShortId: true, - chatContext: { chatGuid: "iMessage;+;anything" }, - }), - ).toThrow(/no longer available/); - }); - - it("accepts same-chat short ids when the caller's target uses a non-canonical handle format", () => { - // Real-world: a cached entry carries the BlueBubbles-normalized handle - // (`+15551234567`) as its chatIdentifier. A tool call like - // `react to: "imessage:(555) 123-4567"` has to project into the same - // chatIdentifier before the guard compares — otherwise the raw handle - // `(555) 123-4567` would fail the mismatch check against the cached - // `+15551234567` and legitimate same-chat reactions/replies would be - // blocked. - const dmEntry = seedMessage({ - accountId: "default", - messageId: "uuid-dm-handle", - chatIdentifier: "+15551234567", - }); - const cachedChatIdentifier = dmEntry.chatIdentifier; - - for (const target of ["imessage:+15551234567", "sms:+15551234567", "+15551234567"]) { - const ctx = buildBlueBubblesChatContextFromTarget(target); - expect(ctx.chatIdentifier, `ctx.chatIdentifier for ${target}`).toBe(cachedChatIdentifier); - expect( - resolveBlueBubblesMessageId(dmEntry.shortId, { - requireKnownShortId: true, - chatContext: ctx, - }), - `resolve for ${target}`, - ).toBe("uuid-dm-handle"); - } - - // Mixed-case email handle: cached as lowercase; caller supplies mixed - // case. Still resolves. - const emailEntry = seedMessage({ - accountId: "default", - messageId: "uuid-email", - chatIdentifier: "user@example.com", - }); - const emailCtx = buildBlueBubblesChatContextFromTarget("imessage:User@Example.COM"); - expect(emailCtx.chatIdentifier).toBe("user@example.com"); - expect( - resolveBlueBubblesMessageId(emailEntry.shortId, { - requireKnownShortId: true, - chatContext: emailCtx, - }), - ).toBe("uuid-email"); - }); -}); diff --git a/extensions/bluebubbles/src/monitor-reply-cache.ts b/extensions/bluebubbles/src/monitor-reply-cache.ts deleted file mode 100644 index 1acae61c31b9..000000000000 --- a/extensions/bluebubbles/src/monitor-reply-cache.ts +++ /dev/null @@ -1,336 +0,0 @@ -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; - -const REPLY_CACHE_MAX = 2000; -const REPLY_CACHE_TTL_MS = 6 * 60 * 60 * 1000; - -type BlueBubblesReplyCacheEntry = { - accountId: string; - messageId: string; - shortId: string; - chatGuid?: string; - chatIdentifier?: string; - chatId?: number; - senderLabel?: string; - body?: string; - timestamp: number; -}; - -// Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body. -const blueBubblesReplyCacheByMessageId = new Map(); - -// Bidirectional maps for short ID ↔ message GUID resolution (token savings optimization) -const blueBubblesShortIdToUuid = new Map(); -const blueBubblesUuidToShortId = new Map(); -let blueBubblesShortIdCounter = 0; - -function generateShortId(): string { - blueBubblesShortIdCounter += 1; - return String(blueBubblesShortIdCounter); -} - -export function rememberBlueBubblesReplyCache( - entry: Omit, -): BlueBubblesReplyCacheEntry { - const messageId = entry.messageId.trim(); - if (!messageId) { - return { ...entry, shortId: "" }; - } - - // Check if we already have a short ID for this GUID - let shortId = blueBubblesUuidToShortId.get(messageId); - if (!shortId) { - shortId = generateShortId(); - blueBubblesShortIdToUuid.set(shortId, messageId); - blueBubblesUuidToShortId.set(messageId, shortId); - } - - const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, messageId, shortId }; - - // Refresh insertion order. - blueBubblesReplyCacheByMessageId.delete(messageId); - blueBubblesReplyCacheByMessageId.set(messageId, fullEntry); - - // Opportunistic prune. - const cutoff = Date.now() - REPLY_CACHE_TTL_MS; - for (const [key, value] of blueBubblesReplyCacheByMessageId) { - if (value.timestamp < cutoff) { - blueBubblesReplyCacheByMessageId.delete(key); - // Clean up short ID mappings for expired entries - if (value.shortId) { - blueBubblesShortIdToUuid.delete(value.shortId); - blueBubblesUuidToShortId.delete(key); - } - continue; - } - break; - } - while (blueBubblesReplyCacheByMessageId.size > REPLY_CACHE_MAX) { - const oldest = blueBubblesReplyCacheByMessageId.keys().next().value; - if (!oldest) { - break; - } - const oldEntry = blueBubblesReplyCacheByMessageId.get(oldest); - blueBubblesReplyCacheByMessageId.delete(oldest); - // Clean up short ID mappings for evicted entries - if (oldEntry?.shortId) { - blueBubblesShortIdToUuid.delete(oldEntry.shortId); - blueBubblesUuidToShortId.delete(oldest); - } - } - - return fullEntry; -} - -export type BlueBubblesChatContext = { - chatGuid?: string; - chatIdentifier?: string; - chatId?: number; -}; - -/** - * Cross-chat guard: compare a cached entry's chat fields with a caller-provided - * context. Returns true when the two clearly reference different chats. - * - * Comparison rules mirror resolveReplyContextFromCache so outbound short-ID - * resolution and inbound reply-context lookup agree on scope: - * - * - If both sides carry a chatGuid and they differ, that is the strongest - * signal of a cross-chat reuse. - * - Otherwise, if the caller has no chatGuid but both sides carry a - * chatIdentifier and they differ, that is also a mismatch. This covers - * handle-only callers (tapback into a DM where the caller only resolved - * a handle) against cached entries that still carry chatGuid from the - * inbound webhook. - * - Otherwise, if the caller has neither chatGuid nor chatIdentifier but - * both sides carry a chatId and they differ, that is also a mismatch. - * - * Absent identifiers on either side are treated as "no information" rather - * than a mismatch, so ambiguous calls fall through as-is. - */ -function isCrossChatMismatch( - cached: BlueBubblesReplyCacheEntry, - ctx: BlueBubblesChatContext, -): boolean { - // Compare each identifier independently based on availability on both sides. - // Earlier versions gated chatIdentifier/chatId comparisons on `!ctxChatGuid`, - // which let any non-empty `ctx.chatGuid` suppress the fallback checks when - // the cached entry happened to lack chatGuid — letting a short id from - // chat A be reused while acting in chat B. - const cachedChatGuid = normalizeOptionalString(cached.chatGuid); - const ctxChatGuid = normalizeOptionalString(ctx.chatGuid); - if (cachedChatGuid && ctxChatGuid) { - return cachedChatGuid !== ctxChatGuid; - } - const cachedChatIdentifier = normalizeOptionalString(cached.chatIdentifier); - const ctxChatIdentifier = normalizeOptionalString(ctx.chatIdentifier); - if (cachedChatIdentifier && ctxChatIdentifier) { - return cachedChatIdentifier !== ctxChatIdentifier; - } - const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined; - const ctxChatId = typeof ctx.chatId === "number" ? ctx.chatId : undefined; - if (cachedChatId !== undefined && ctxChatId !== undefined) { - return cachedChatId !== ctxChatId; - } - return false; -} - -function describeChatForError(values: { - chatGuid?: string; - chatIdentifier?: string; - chatId?: number; -}): string { - // Surface only the *shape* of the chat target, never the raw identifier, - // to avoid leaking phone numbers / email addresses / chat GUIDs into - // error messages that may end up in agent transcripts, tool results, - // remote channel deliveries, or third-party log aggregators. - const parts: string[] = []; - if (normalizeOptionalString(values.chatGuid)) { - parts.push("chatGuid="); - } - if (normalizeOptionalString(values.chatIdentifier)) { - parts.push("chatIdentifier="); - } - if (typeof values.chatId === "number") { - parts.push("chatId="); - } - return parts.length === 0 ? "" : parts.join(", "); -} - -function describeMessageIdForError(inputId: string, inputKind: "short" | "uuid"): string { - // Don't reflect the raw message id back into an error message that may end - // up in agent transcripts / tool results / log streams. Surface only the - // shape (numeric short id length range, or a UUID prefix) so callers can - // still tell which message id they typed (CWE-117 / CWE-200). - if (inputKind === "short") { - const len = inputId.length; - return ``; - } - // For UUID input, expose just an 8-char prefix; consumer can correlate - // against full GUID via the trace if needed. - return ``; -} - -function buildCrossChatError( - inputId: string, - inputKind: "short" | "uuid", - cached: BlueBubblesReplyCacheEntry, - ctx: BlueBubblesChatContext, -): Error { - const remediation = - inputKind === "short" - ? `Retry with the full message GUID to avoid cross-chat reactions/replies landing in the wrong conversation.` - : `Retry with the correct chat target — even the full GUID cannot be reused across chats.`; - return new Error( - `BlueBubbles message id ${describeMessageIdForError(inputId, inputKind)} belongs to a different chat ` + - `(${describeChatForError(cached)}) than the current call target ` + - `(${describeChatForError(ctx)}). ${remediation}`, - ); -} - -function hasChatScope(ctx?: BlueBubblesChatContext): boolean { - if (!ctx) { - return false; - } - return Boolean( - normalizeOptionalString(ctx.chatGuid) || - normalizeOptionalString(ctx.chatIdentifier) || - typeof ctx.chatId === "number", - ); -} - -/** - * Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles GUID. - * Returns the input unchanged if it's already a GUID or not found in the mapping. - * - * When `chatContext` is provided, the resolved UUID's cached chat must match - * the caller's chat or the call throws. This prevents a message id that points - * at a message in chat A from being silently reused in chat B — the common - * symptom being tapbacks and quoted replies landing in the wrong conversation - * (e.g. a group reaction showing up in a DM) because short IDs are allocated - * from a single global counter across every account and chat. - * - * The guard runs on both numeric short ids AND full GUIDs: an agent can paste - * a GUID it harvested from history, a previous tool result, or another chat's - * transcript, and that path used to bypass the cross-chat check entirely. - */ -export function resolveBlueBubblesMessageId( - shortOrUuid: string, - opts?: { requireKnownShortId?: boolean; chatContext?: BlueBubblesChatContext }, -): string { - const trimmed = shortOrUuid.trim(); - if (!trimmed) { - return trimmed; - } - - // If it looks like a short ID (numeric), try to resolve it - if (/^\d+$/.test(trimmed)) { - // Privileged callers (requireKnownShortId=true) MUST scope the resolution - // to a chat. Without a chat scope the cross-chat guard cannot detect when - // the short id belongs to a different chat than the action target — short - // ids are allocated from a single global counter across every account and - // chat, so an empty `chatContext={}` would otherwise let an action operate - // on a message in the wrong conversation (CWE-285). - if (opts?.requireKnownShortId && !hasChatScope(opts.chatContext)) { - throw new Error( - `BlueBubbles short message id "${describeMessageIdForError(trimmed, "short")}" requires a chat scope (chatGuid / chatIdentifier / chatId or a --to target).`, - ); - } - const uuid = blueBubblesShortIdToUuid.get(trimmed); - if (uuid) { - if (opts?.chatContext) { - const cached = blueBubblesReplyCacheByMessageId.get(uuid); - if (cached && isCrossChatMismatch(cached, opts.chatContext)) { - throw buildCrossChatError(trimmed, "short", cached, opts.chatContext); - } - } - return uuid; - } - if (opts?.requireKnownShortId) { - throw new Error( - `BlueBubbles short message id ${describeMessageIdForError(trimmed, "short")} is no longer available. Use MessageSidFull.`, - ); - } - return trimmed; - } - - // Full GUID input — guard still applies. Cache miss falls through to - // returning the input unchanged so callers that supply a fresh-from-the-wire - // GUID (not yet seen by reply cache) keep working. - if (opts?.chatContext) { - const cached = blueBubblesReplyCacheByMessageId.get(trimmed); - if (cached && isCrossChatMismatch(cached, opts.chatContext)) { - throw buildCrossChatError(trimmed, "uuid", cached, opts.chatContext); - } - } - return trimmed; -} - -/** - * Resets the short ID state. Only use in tests. - * @internal - */ -export function _resetBlueBubblesShortIdState(): void { - blueBubblesShortIdToUuid.clear(); - blueBubblesUuidToShortId.clear(); - blueBubblesReplyCacheByMessageId.clear(); - blueBubblesShortIdCounter = 0; -} - -/** - * Gets the short ID for a message GUID, if one exists. - */ -export function getShortIdForUuid(uuid: string): string | undefined { - return blueBubblesUuidToShortId.get(uuid.trim()); -} - -export function resolveReplyContextFromCache(params: { - accountId: string; - replyToId: string; - chatGuid?: string; - chatIdentifier?: string; - chatId?: number; -}): BlueBubblesReplyCacheEntry | null { - const replyToId = params.replyToId.trim(); - if (!replyToId) { - return null; - } - - const cached = blueBubblesReplyCacheByMessageId.get(replyToId); - if (!cached) { - return null; - } - if (cached.accountId !== params.accountId) { - return null; - } - - const cutoff = Date.now() - REPLY_CACHE_TTL_MS; - if (cached.timestamp < cutoff) { - blueBubblesReplyCacheByMessageId.delete(replyToId); - return null; - } - - const chatGuid = normalizeOptionalString(params.chatGuid); - const chatIdentifier = normalizeOptionalString(params.chatIdentifier); - const cachedChatGuid = normalizeOptionalString(cached.chatGuid); - const cachedChatIdentifier = normalizeOptionalString(cached.chatIdentifier); - const chatId = typeof params.chatId === "number" ? params.chatId : undefined; - const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined; - - // Avoid cross-chat collisions if we have identifiers. - if (chatGuid && cachedChatGuid && chatGuid !== cachedChatGuid) { - return null; - } - if ( - !chatGuid && - chatIdentifier && - cachedChatIdentifier && - chatIdentifier !== cachedChatIdentifier - ) { - return null; - } - if (!chatGuid && !chatIdentifier && chatId && cachedChatId && chatId !== cachedChatId) { - return null; - } - - return cached; -} diff --git a/extensions/bluebubbles/src/monitor-reply-fetch.test.ts b/extensions/bluebubbles/src/monitor-reply-fetch.test.ts deleted file mode 100644 index ad4531740c27..000000000000 --- a/extensions/bluebubbles/src/monitor-reply-fetch.test.ts +++ /dev/null @@ -1,498 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { BlueBubblesClient, createBlueBubblesClientFromParts } from "./client.js"; -import { - _resetBlueBubblesShortIdState, - getShortIdForUuid, - resolveReplyContextFromCache, -} from "./monitor-reply-cache.js"; -import { - _resetBlueBubblesReplyFetchState, - fetchBlueBubblesReplyContext, -} from "./monitor-reply-fetch.js"; - -type FactoryParams = Parameters[0]; -type RequestParams = Parameters[0]; - -const baseParams = { - accountId: "default", - baseUrl: "http://localhost:1234", - password: "s3cret", -} as const; - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); -} - -/** - * Build a fake client factory that records every constructor + request call - * and serves a queue of canned responses. Returns the factory plus a `calls` - * accessor so tests can assert on factory params (SSRF mode inputs) and - * request params (path, timeout). - */ -function makeFakeClient( - responses: - | Array Promise)> - | (() => Response | Promise), -) { - const factoryCalls: FactoryParams[] = []; - const requestCalls: RequestParams[] = []; - let cursor = 0; - const factory = vi.fn((factoryParams: FactoryParams): BlueBubblesClient => { - factoryCalls.push(factoryParams); - const request = vi.fn(async (requestParams: RequestParams) => { - requestCalls.push(requestParams); - if (typeof responses === "function") { - return await responses(); - } - const next = responses[cursor++]; - if (next instanceof Error) { - throw next; - } - if (typeof next === "function") { - return await next(); - } - return next ?? new Response("", { status: 500 }); - }); - return { request } as unknown as BlueBubblesClient; - }); - return { factory, factoryCalls, requestCalls }; -} - -beforeEach(() => { - _resetBlueBubblesReplyFetchState(); - _resetBlueBubblesShortIdState(); -}); - -afterEach(() => { - _resetBlueBubblesReplyFetchState(); - _resetBlueBubblesShortIdState(); -}); - -describe("fetchBlueBubblesReplyContext", () => { - it("returns null when replyToId is empty", async () => { - const { factory } = makeFakeClient([]); - const result = await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: " ", - clientFactory: factory, - }); - expect(result).toBeNull(); - expect(factory).not.toHaveBeenCalled(); - }); - - it("returns null when baseUrl or password are missing", async () => { - const { factory } = makeFakeClient([]); - expect( - await fetchBlueBubblesReplyContext({ - accountId: "default", - baseUrl: "", - password: "x", - replyToId: "msg-1", - clientFactory: factory, - }), - ).toBeNull(); - expect( - await fetchBlueBubblesReplyContext({ - accountId: "default", - baseUrl: "http://localhost:1234", - password: "", - replyToId: "msg-1", - clientFactory: factory, - }), - ).toBeNull(); - expect(factory).not.toHaveBeenCalled(); - }); - - it("rejects pathological reply ids before issuing a request", async () => { - // Each case is rejected for a different reason: empty/whitespace, trailing - // slash that yields an empty bare segment, characters outside the GUID - // charset, or length cap. Note: `../etc/passwd` is *not* pathological — - // sanitizeReplyToId strips to `passwd`, which is a syntactically valid - // bare GUID. The path goes through encodeURIComponent, so there is no - // traversal; the server returns 404 and the caller proceeds with null. - const cases = ["", " ", "abc/", "abc def", "abc?x=1", "a".repeat(129)]; - for (const replyToId of cases) { - const { factory } = makeFakeClient([]); - const result = await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId, - clientFactory: factory, - }); - expect(result, `replyToId=${JSON.stringify(replyToId)}`).toBeNull(); - expect(factory, `replyToId=${JSON.stringify(replyToId)}`).not.toHaveBeenCalled(); - } - }); - - it("strips part-index prefix (`p:0/` → ``) before fetching", async () => { - const { factory, requestCalls } = makeFakeClient([ - jsonResponse({ data: { text: "hi", handle: { address: "+15551234567" } } }), - ]); - const result = await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "p:0/msg-bare-guid", - clientFactory: factory, - }); - expect(result?.body).toBe("hi"); - expect(requestCalls[0]?.path).toBe("/api/v1/message/msg-bare-guid"); - }); - - it("populates the reply cache for the original prefixed reply id", async () => { - const { factory } = makeFakeClient([ - jsonResponse({ data: { text: "cached prefix", handle: { address: "+15551112222" } } }), - ]); - await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "p:0/msg-prefixed-cache", - chatGuid: "iMessage;-;+15551112222", - clientFactory: factory, - }); - const cached = resolveReplyContextFromCache({ - accountId: "default", - replyToId: "p:0/msg-prefixed-cache", - chatGuid: "iMessage;-;+15551112222", - }); - expect(cached?.body).toBe("cached prefix"); - expect(cached?.senderLabel).toBe("+15551112222"); - }); - - it("does not cache non-part-index slash prefixes as aliases", async () => { - const { factory, requestCalls } = makeFakeClient([ - jsonResponse({ data: { text: "cached bare only", handle: { address: "+15551112222" } } }), - ]); - await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "../etc/passwd", - chatGuid: "iMessage;-;+15551112222", - clientFactory: factory, - }); - expect(requestCalls[0]?.path).toBe("/api/v1/message/passwd"); - expect( - resolveReplyContextFromCache({ - accountId: "default", - replyToId: "passwd", - chatGuid: "iMessage;-;+15551112222", - })?.body, - ).toBe("cached bare only"); - expect( - resolveReplyContextFromCache({ - accountId: "default", - replyToId: "../etc/passwd", - chatGuid: "iMessage;-;+15551112222", - }), - ).toBeNull(); - expect(getShortIdForUuid("../etc/passwd")).toBeUndefined(); - }); - - it("fetches the BB API and returns body + normalized sender on success", async () => { - const { factory, requestCalls } = makeFakeClient([ - jsonResponse({ - data: { - text: " hello world ", - handle: { address: " +15551234567 " }, - }, - }), - ]); - const result = await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "msg-1", - clientFactory: factory, - }); - expect(result).toEqual({ body: "hello world", sender: "+15551234567" }); - expect(factory).toHaveBeenCalledTimes(1); - expect(requestCalls[0]?.method).toBe("GET"); - expect(requestCalls[0]?.path).toBe("/api/v1/message/msg-1"); - }); - - it("lowercases email handles via normalizeBlueBubblesHandle", async () => { - const { factory } = makeFakeClient([ - jsonResponse({ data: { text: "hi", handle: { address: "Foo@Example.COM" } } }), - ]); - const result = await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "msg-email", - clientFactory: factory, - }); - expect(result?.sender).toBe("foo@example.com"); - }); - - it("populates the reply cache so subsequent lookups hit RAM", async () => { - const { factory } = makeFakeClient([ - jsonResponse({ data: { text: "cached me", handle: { address: "+15551112222" } } }), - ]); - await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "msg-cache", - chatGuid: "iMessage;-;+15551112222", - clientFactory: factory, - }); - const cached = resolveReplyContextFromCache({ - accountId: "default", - replyToId: "msg-cache", - chatGuid: "iMessage;-;+15551112222", - }); - expect(cached?.body).toBe("cached me"); - expect(cached?.senderLabel).toBe("+15551112222"); - expect(cached?.shortId).toBeTruthy(); - }); - - it("falls back through text → body → subject for the message body", async () => { - const { factory } = makeFakeClient([ - jsonResponse({ data: { body: "from body field" } }), - jsonResponse({ data: { subject: "from subject field" } }), - ]); - const a = await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "msg-a", - clientFactory: factory, - }); - expect(a?.body).toBe("from body field"); - const b = await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "msg-b", - clientFactory: factory, - }); - expect(b?.body).toBe("from subject field"); - }); - - it("falls back through handle.address → handle.id → senderId → sender for the sender", async () => { - const { factory } = makeFakeClient([ - jsonResponse({ data: { text: "x", handle: { id: "+15550000001" } } }), - jsonResponse({ data: { text: "x", senderId: "+15550000002" } }), - jsonResponse({ data: { text: "x", sender: "+15550000003" } }), - ]); - const a = await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "h-a", - clientFactory: factory, - }); - expect(a?.sender).toBe("+15550000001"); - const b = await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "h-b", - clientFactory: factory, - }); - expect(b?.sender).toBe("+15550000002"); - const c = await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "h-c", - clientFactory: factory, - }); - expect(c?.sender).toBe("+15550000003"); - }); - - it("accepts the BB response either wrapped under `data` or at the top level", async () => { - const { factory } = makeFakeClient([ - jsonResponse({ text: "no envelope", handle: { address: "user@host" } }), - ]); - const result = await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "msg-flat", - clientFactory: factory, - }); - expect(result?.body).toBe("no envelope"); - expect(result?.sender).toBe("user@host"); - }); - - it("returns null on non-2xx without throwing", async () => { - const { factory } = makeFakeClient([new Response("nope", { status: 404 })]); - const result = await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "missing", - clientFactory: factory, - }); - expect(result).toBeNull(); - }); - - it("returns null when the underlying request throws (network error / timeout)", async () => { - const { factory } = makeFakeClient([new Error("ECONNRESET")]); - const result = await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "boom", - clientFactory: factory, - }); - expect(result).toBeNull(); - }); - - it("returns null when JSON parsing fails", async () => { - const { factory } = makeFakeClient([ - new Response("not json", { status: 200, headers: { "content-type": "text/plain" } }), - ]); - const result = await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "garbage", - clientFactory: factory, - }); - expect(result).toBeNull(); - }); - - it("returns null when neither body nor sender can be extracted", async () => { - const { factory } = makeFakeClient([jsonResponse({ data: { irrelevant: 1 } })]); - const result = await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "blank", - clientFactory: factory, - }); - expect(result).toBeNull(); - }); - - it("dedupes concurrent fetches for the same accountId + replyToId", async () => { - let resolveOnce: (value: Response) => void = () => {}; - const pending = new Promise((resolve) => { - resolveOnce = resolve; - }); - const { factory } = makeFakeClient(() => pending); - const a = fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "shared", - clientFactory: factory, - }); - const b = fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "shared", - clientFactory: factory, - }); - // Only one client construction; in-flight dedupe coalesces both callers. - expect(factory).toHaveBeenCalledTimes(1); - resolveOnce( - jsonResponse({ data: { text: "shared body", handle: { address: "+15558675309" } } }), - ); - const [resA, resB] = await Promise.all([a, b]); - expect(resA).toEqual({ body: "shared body", sender: "+15558675309" }); - expect(resB).toEqual(resA); - }); - - it("does not dedupe across different accountIds", async () => { - const { factory } = makeFakeClient([ - jsonResponse({ data: { text: "a", handle: { address: "+15551000001" } } }), - jsonResponse({ data: { text: "b", handle: { address: "+15551000002" } } }), - ]); - const [a, b] = await Promise.all([ - fetchBlueBubblesReplyContext({ - ...baseParams, - accountId: "acct-a", - replyToId: "same", - clientFactory: factory, - }), - fetchBlueBubblesReplyContext({ - ...baseParams, - accountId: "acct-b", - replyToId: "same", - clientFactory: factory, - }), - ]); - expect(factory).toHaveBeenCalledTimes(2); - expect(a?.body).toBe("a"); - expect(b?.body).toBe("b"); - }); - - it("releases the in-flight slot once a request completes (next call re-fetches)", async () => { - const { factory } = makeFakeClient([ - jsonResponse({ data: { text: "first", handle: { address: "+15552000001" } } }), - jsonResponse({ data: { text: "second", handle: { address: "+15552000002" } } }), - ]); - const first = await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "msg-x", - clientFactory: factory, - }); - const second = await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "msg-x", - clientFactory: factory, - }); - expect(factory).toHaveBeenCalledTimes(2); - expect(first?.body).toBe("first"); - expect(second?.body).toBe("second"); - }); - - it("threads explicit private-network opt-in through to the typed client (mode 1)", async () => { - const { factory, factoryCalls } = makeFakeClient([ - jsonResponse({ data: { text: "x", handle: { address: "+15553000001" } } }), - ]); - await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "ssrf-on", - accountConfig: { network: { dangerouslyAllowPrivateNetwork: true } }, - clientFactory: factory, - }); - expect(factoryCalls[0]?.allowPrivateNetwork).toBe(true); - expect(factoryCalls[0]?.allowPrivateNetworkConfig).toBe(true); - }); - - it("treats local/loopback baseUrls as implicit private-network opt-in (mode 1)", async () => { - // `http://localhost:1234` is a private hostname; without an explicit - // opt-out the resolver treats this as the self-hosted case, matching - // resolveBlueBubblesEffectiveAllowPrivateNetworkFromConfig. - const { factory, factoryCalls } = makeFakeClient([ - jsonResponse({ data: { text: "x", handle: { address: "+15554000001" } } }), - ]); - await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "ssrf-implicit", - clientFactory: factory, - }); - expect(factoryCalls[0]?.allowPrivateNetwork).toBe(true); - expect(factoryCalls[0]?.allowPrivateNetworkConfig).toBeUndefined(); - }); - - it("does not mark public BB hosts as private-network when opt-in is absent (mode 2)", async () => { - const { factory, factoryCalls } = makeFakeClient([ - jsonResponse({ data: { text: "x", handle: { address: "user@example.com" } } }), - ]); - await fetchBlueBubblesReplyContext({ - accountId: "default", - baseUrl: "https://bb.example.com", - password: "s3cret", - replyToId: "ssrf-public", - clientFactory: factory, - }); - expect(factoryCalls[0]?.allowPrivateNetwork).toBe(false); - }); - - it("propagates explicit opt-out on a private host (mode 3)", async () => { - const { factory, factoryCalls } = makeFakeClient([ - jsonResponse({ data: { text: "x", handle: { address: "+15555000001" } } }), - ]); - await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "ssrf-opt-out", - accountConfig: { network: { dangerouslyAllowPrivateNetwork: false } }, - clientFactory: factory, - }); - expect(factoryCalls[0]?.allowPrivateNetwork).toBe(false); - expect(factoryCalls[0]?.allowPrivateNetworkConfig).toBe(false); - }); - - it("never passes undefined for allowPrivateNetwork to the typed client (regression for #71820 codex review)", async () => { - // The typed client owns SSRF policy resolution internally and cannot - // produce an undefined policy. This test guards the invariant at the - // call boundary: we always pass a concrete boolean for - // allowPrivateNetwork so the resolver picks a deterministic mode. - const { factory, factoryCalls } = makeFakeClient([ - jsonResponse({ data: { text: "x", handle: { address: "+15556000001" } } }), - ]); - await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "ssrf-defined", - clientFactory: factory, - }); - expect(typeof factoryCalls[0]?.allowPrivateNetwork).toBe("boolean"); - }); - - it("uses the configured timeout on both the factory and the request call", async () => { - const { factory, factoryCalls, requestCalls } = makeFakeClient([ - jsonResponse({ data: { text: "x", handle: { address: "+15555000001" } } }), - ]); - await fetchBlueBubblesReplyContext({ - ...baseParams, - replyToId: "tm", - timeoutMs: 1234, - clientFactory: factory, - }); - expect(factoryCalls[0]?.timeoutMs).toBe(1234); - expect(requestCalls[0]?.timeoutMs).toBe(1234); - }); -}); diff --git a/extensions/bluebubbles/src/monitor-reply-fetch.ts b/extensions/bluebubbles/src/monitor-reply-fetch.ts deleted file mode 100644 index ab09df6f47f7..000000000000 --- a/extensions/bluebubbles/src/monitor-reply-fetch.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { - resolveBlueBubblesEffectiveAllowPrivateNetworkFromConfig, - resolveBlueBubblesPrivateNetworkConfigValue, -} from "./accounts-normalization.js"; -import { createBlueBubblesClientFromParts } from "./client.js"; -import { rememberBlueBubblesReplyCache } from "./monitor-reply-cache.js"; -import { normalizeBlueBubblesHandle } from "./targets.js"; -import type { BlueBubblesAccountConfig } from "./types.js"; - -const DEFAULT_REPLY_FETCH_TIMEOUT_MS = 5_000; - -// Reject pathological GUIDs before they reach the API path: a trailing slash -// would yield an empty bare GUID and turn the request into a list query -// against `/api/v1/message/`; arbitrary characters could let a malformed -// payload steer encoded path segments. Real BlueBubbles GUIDs are alnum + the -// punctuation set below; 128 chars is comfortable headroom (CWE-20). -const REPLY_TO_ID_PATTERN = /^[A-Za-z0-9._:-]+$/; -const REPLY_TO_ID_MAX_LENGTH = 128; -const PART_INDEX_REPLY_TO_ID_PATTERN = /^p:\d{1,10}\/([A-Za-z0-9._:-]+)$/; -const PART_INDEX_REPLY_TO_ID_MAX_LENGTH = REPLY_TO_ID_MAX_LENGTH + "p:".length + 10 + "/".length; - -type BlueBubblesReplyFetchResult = { - body?: string; - sender?: string; -}; - -/** - * In-flight dedupe so concurrent webhooks for replies to the same message - * (e.g., several recipients in a group chat replying near-simultaneously) - * coalesce into a single BlueBubbles HTTP fetch. - * - * Key shape: `${accountId}:${replyToId}` to keep accounts isolated. - */ -const inflight = new Map>(); - -/** - * @internal Reset shared module state. Test-only. - */ -export function _resetBlueBubblesReplyFetchState(): void { - inflight.clear(); -} - -type FetchBlueBubblesReplyContextParams = { - accountId: string; - replyToId: string; - baseUrl: string; - password: string; - /** - * Optional account config — used to resolve the SSRF policy for this fetch - * via the same three-mode resolver the BlueBubbles client uses. Even when - * omitted the request is still SSRF-guarded; the typed client routes - * through the resolver internally and never returns `undefined`. - */ - accountConfig?: BlueBubblesAccountConfig; - /** Optional chat scope used to populate the reply cache for subsequent hits. */ - chatGuid?: string; - chatIdentifier?: string; - chatId?: number; - /** Defaults to 5_000 ms. */ - timeoutMs?: number; - /** Override the typed client factory. Test seam. */ - clientFactory?: typeof createBlueBubblesClientFromParts; -}; - -/** - * Best-effort fallback: when the local in-memory reply cache misses, ask the - * BlueBubbles HTTP API for the original message so the agent still gets reply - * context. Returns `null` on any failure (network error, non-2xx, parse error, - * empty payload). Never throws. - * - * On success, the cache is populated so subsequent replies to the same message - * resolve from RAM without another round-trip. - * - * Cache misses happen in legitimate, common deployments: multi-instance setups - * sharing one BB account, container/process restarts, cross-tenant shared - * groups, and long-lived chats where TTL/LRU has evicted the message. - */ -export function fetchBlueBubblesReplyContext( - params: FetchBlueBubblesReplyContextParams, -): Promise { - const replyToId = sanitizeReplyToId(params.replyToId); - if (!replyToId || !params.baseUrl || !params.password) { - return Promise.resolve(null); - } - const key = `${params.accountId}:${replyToId}`; - const existing = inflight.get(key); - if (existing) { - return existing; - } - const promise = runFetch(params, replyToId).finally(() => { - inflight.delete(key); - }); - inflight.set(key, promise); - return promise; -} - -/** - * Strip a part-index prefix (`p:0/` → ``) and validate the result - * against the GUID character set + length cap. Returns null when the id is - * empty or cannot safely be used as a path segment. - */ -function sanitizeReplyToId(raw: string): string | null { - const trimmed = raw.trim(); - if (!trimmed) { - return null; - } - const bare = trimmed.includes("/") ? (trimmed.split("/").pop() ?? "") : trimmed; - if (!bare || bare.length > REPLY_TO_ID_MAX_LENGTH || !REPLY_TO_ID_PATTERN.test(bare)) { - return null; - } - return bare; -} - -function normalizePartIndexReplyToIdAlias(raw: string, bareReplyToId: string): string | null { - const trimmed = raw.trim(); - if (trimmed.length > PART_INDEX_REPLY_TO_ID_MAX_LENGTH) { - return null; - } - const match = PART_INDEX_REPLY_TO_ID_PATTERN.exec(trimmed); - if (!match || match[1] !== bareReplyToId) { - return null; - } - return trimmed; -} - -async function runFetch( - params: FetchBlueBubblesReplyContextParams, - replyToId: string, -): Promise { - const factory = params.clientFactory ?? createBlueBubblesClientFromParts; - // Route through the typed BlueBubbles client. `client.request()` always - // applies the SSRF policy resolved via the canonical three-mode helper - // (mode 1: explicit private-network opt-in, mode 2: hostname allowlist for - // trusted self-hosted servers, mode 3: default-deny guard). Going through - // the typed surface guarantees consistency with every other BB client - // request and removes the risk of an `undefined` policy slipping past the - // guard. (PR #71820 review; same threat model as #68234.) - const client = factory({ - accountId: params.accountId, - baseUrl: params.baseUrl, - password: params.password, - allowPrivateNetwork: resolveBlueBubblesEffectiveAllowPrivateNetworkFromConfig({ - baseUrl: params.baseUrl, - config: params.accountConfig, - }), - allowPrivateNetworkConfig: resolveBlueBubblesPrivateNetworkConfigValue(params.accountConfig), - timeoutMs: params.timeoutMs ?? DEFAULT_REPLY_FETCH_TIMEOUT_MS, - }); - try { - const response = await client.request({ - method: "GET", - path: `/api/v1/message/${encodeURIComponent(replyToId)}`, - timeoutMs: params.timeoutMs ?? DEFAULT_REPLY_FETCH_TIMEOUT_MS, - }); - if (!response.ok) { - return null; - } - const json = (await response.json()) as Record; - const data = (json.data ?? json) as Record | undefined; - if (!data || typeof data !== "object") { - return null; - } - const body = extractBody(data); - const sender = extractSender(data); - if (!body && !sender) { - return null; - } - const cacheEntry = { - accountId: params.accountId, - messageId: replyToId, - chatGuid: params.chatGuid, - chatIdentifier: params.chatIdentifier, - chatId: params.chatId, - senderLabel: sender, - body, - timestamp: Date.now(), - }; - rememberBlueBubblesReplyCache(cacheEntry); - const partIndexReplyToId = normalizePartIndexReplyToIdAlias(params.replyToId, replyToId); - if (partIndexReplyToId) { - rememberBlueBubblesReplyCache({ - ...cacheEntry, - messageId: partIndexReplyToId, - }); - } - return { body, sender }; - } catch { - // Best-effort: swallow network/parse errors. Caller proceeds with empty - // reply context, which matches existing pre-fallback behavior. - return null; - } -} - -function extractBody(data: Record): string | undefined { - return ( - normalizeOptionalString(data.text) ?? - normalizeOptionalString(data.body) ?? - normalizeOptionalString(data.subject) - ); -} - -function asRecord(value: unknown): Record | undefined { - return value !== null && typeof value === "object" - ? (value as Record) - : undefined; -} - -function extractSender(data: Record): string | undefined { - const handle = asRecord(data.handle) ?? asRecord(data.sender); - const raw = - normalizeOptionalString(handle?.address) ?? - normalizeOptionalString(handle?.id) ?? - normalizeOptionalString(data.senderId) ?? - normalizeOptionalString(data.sender); - if (!raw) { - return undefined; - } - return normalizeBlueBubblesHandle(raw) || raw; -} diff --git a/extensions/bluebubbles/src/monitor-self-chat-cache.test.ts b/extensions/bluebubbles/src/monitor-self-chat-cache.test.ts deleted file mode 100644 index 3e843f6943d1..000000000000 --- a/extensions/bluebubbles/src/monitor-self-chat-cache.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - hasBlueBubblesSelfChatCopy, - rememberBlueBubblesSelfChatCopy, - resetBlueBubblesSelfChatCache, -} from "./monitor-self-chat-cache.js"; - -describe("BlueBubbles self-chat cache", () => { - const directLookup = { - accountId: "default", - chatGuid: "iMessage;-;+15551234567", - senderId: "+15551234567", - } as const; - - afterEach(() => { - resetBlueBubblesSelfChatCache(); - vi.useRealTimers(); - }); - - it("matches repeated lookups for the same scope, timestamp, and text", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-07T00:00:00Z")); - - rememberBlueBubblesSelfChatCopy({ - ...directLookup, - body: " hello\r\nworld ", - timestamp: 123, - }); - - expect( - hasBlueBubblesSelfChatCopy({ - ...directLookup, - body: "hello\nworld", - timestamp: 123, - }), - ).toBe(true); - }); - - it("canonicalizes DM scope across chatIdentifier and chatGuid", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-07T00:00:00Z")); - - rememberBlueBubblesSelfChatCopy({ - accountId: "default", - chatIdentifier: "+15551234567", - senderId: "+15551234567", - body: "hello", - timestamp: 123, - }); - - expect( - hasBlueBubblesSelfChatCopy({ - accountId: "default", - chatGuid: "iMessage;-;+15551234567", - senderId: "+15551234567", - body: "hello", - timestamp: 123, - }), - ).toBe(true); - - resetBlueBubblesSelfChatCache(); - - rememberBlueBubblesSelfChatCopy({ - accountId: "default", - chatGuid: "iMessage;-;+15551234567", - senderId: "+15551234567", - body: "hello", - timestamp: 123, - }); - - expect( - hasBlueBubblesSelfChatCopy({ - accountId: "default", - chatIdentifier: "+15551234567", - senderId: "+15551234567", - body: "hello", - timestamp: 123, - }), - ).toBe(true); - }); - - it("expires entries after the ttl window", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-07T00:00:00Z")); - - rememberBlueBubblesSelfChatCopy({ - ...directLookup, - body: "hello", - timestamp: 123, - }); - - vi.advanceTimersByTime(11_001); - - expect( - hasBlueBubblesSelfChatCopy({ - ...directLookup, - body: "hello", - timestamp: 123, - }), - ).toBe(false); - }); - - it("evicts older entries when the cache exceeds its cap", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-07T00:00:00Z")); - - for (let i = 0; i < 513; i += 1) { - rememberBlueBubblesSelfChatCopy({ - ...directLookup, - body: `message-${i}`, - timestamp: i, - }); - vi.advanceTimersByTime(1_001); - } - - expect( - hasBlueBubblesSelfChatCopy({ - ...directLookup, - body: "message-0", - timestamp: 0, - }), - ).toBe(false); - expect( - hasBlueBubblesSelfChatCopy({ - ...directLookup, - body: "message-512", - timestamp: 512, - }), - ).toBe(true); - }); - - it("enforces the cache cap even when cleanup is throttled", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-07T00:00:00Z")); - - for (let i = 0; i < 513; i += 1) { - rememberBlueBubblesSelfChatCopy({ - ...directLookup, - body: `burst-${i}`, - timestamp: i, - }); - } - - expect( - hasBlueBubblesSelfChatCopy({ - ...directLookup, - body: "burst-0", - timestamp: 0, - }), - ).toBe(false); - expect( - hasBlueBubblesSelfChatCopy({ - ...directLookup, - body: "burst-512", - timestamp: 512, - }), - ).toBe(true); - }); - - it("does not collide long texts that differ only in the middle", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-07T00:00:00Z")); - - const prefix = "a".repeat(256); - const suffix = "b".repeat(256); - const longBodyA = `${prefix}${"x".repeat(300)}${suffix}`; - const longBodyB = `${prefix}${"y".repeat(300)}${suffix}`; - - rememberBlueBubblesSelfChatCopy({ - ...directLookup, - body: longBodyA, - timestamp: 123, - }); - - expect( - hasBlueBubblesSelfChatCopy({ - ...directLookup, - body: longBodyA, - timestamp: 123, - }), - ).toBe(true); - expect( - hasBlueBubblesSelfChatCopy({ - ...directLookup, - body: longBodyB, - timestamp: 123, - }), - ).toBe(false); - }); -}); diff --git a/extensions/bluebubbles/src/monitor-self-chat-cache.ts b/extensions/bluebubbles/src/monitor-self-chat-cache.ts deleted file mode 100644 index 4f57a688272e..000000000000 --- a/extensions/bluebubbles/src/monitor-self-chat-cache.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { createHash } from "node:crypto"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; -import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js"; - -type SelfChatCacheKeyParts = { - accountId: string; - chatGuid?: string; - chatIdentifier?: string; - chatId?: number; - senderId: string; -}; - -type SelfChatLookup = SelfChatCacheKeyParts & { - body?: string; - timestamp?: number; -}; - -const SELF_CHAT_TTL_MS = 10_000; -const MAX_SELF_CHAT_CACHE_ENTRIES = 512; -const CLEANUP_MIN_INTERVAL_MS = 1_000; -const MAX_SELF_CHAT_BODY_CHARS = 32_768; -const cache = new Map(); -let lastCleanupAt = 0; - -function normalizeBody(body: string | undefined): string | null { - if (!body) { - return null; - } - const bounded = - body.length > MAX_SELF_CHAT_BODY_CHARS ? body.slice(0, MAX_SELF_CHAT_BODY_CHARS) : body; - const normalized = bounded.replace(/\r\n?/g, "\n").trim(); - return normalized ? normalized : null; -} - -function isUsableTimestamp(timestamp: number | undefined): timestamp is number { - return typeof timestamp === "number" && Number.isFinite(timestamp); -} - -function digestText(text: string): string { - return createHash("sha256").update(text).digest("base64url"); -} - -function resolveCanonicalChatTarget(parts: SelfChatCacheKeyParts): string | null { - const handleFromGuid = parts.chatGuid ? extractHandleFromChatGuid(parts.chatGuid) : null; - if (handleFromGuid) { - return handleFromGuid; - } - - const normalizedIdentifier = normalizeBlueBubblesHandle(parts.chatIdentifier ?? ""); - if (normalizedIdentifier) { - return normalizedIdentifier; - } - - return ( - normalizeOptionalString(parts.chatGuid) ?? - normalizeOptionalString(parts.chatIdentifier) ?? - (typeof parts.chatId === "number" ? String(parts.chatId) : null) - ); -} - -function buildScope(parts: SelfChatCacheKeyParts): string { - const target = resolveCanonicalChatTarget(parts) ?? parts.senderId; - return `${parts.accountId}:${target}`; -} - -function cleanupExpired(now = Date.now()): void { - if ( - lastCleanupAt !== 0 && - now >= lastCleanupAt && - now - lastCleanupAt < CLEANUP_MIN_INTERVAL_MS - ) { - return; - } - lastCleanupAt = now; - for (const [key, seenAt] of cache.entries()) { - if (now - seenAt > SELF_CHAT_TTL_MS) { - cache.delete(key); - } - } -} - -function enforceSizeCap(): void { - while (cache.size > MAX_SELF_CHAT_CACHE_ENTRIES) { - const oldestKey = cache.keys().next().value; - if (typeof oldestKey !== "string") { - break; - } - cache.delete(oldestKey); - } -} - -function buildKey(lookup: SelfChatLookup): string | null { - const body = normalizeBody(lookup.body); - if (!body || !isUsableTimestamp(lookup.timestamp)) { - return null; - } - return `${buildScope(lookup)}:${lookup.timestamp}:${digestText(body)}`; -} - -export function rememberBlueBubblesSelfChatCopy(lookup: SelfChatLookup): void { - cleanupExpired(); - const key = buildKey(lookup); - if (!key) { - return; - } - cache.set(key, Date.now()); - enforceSizeCap(); -} - -export function hasBlueBubblesSelfChatCopy(lookup: SelfChatLookup): boolean { - cleanupExpired(); - const key = buildKey(lookup); - if (!key) { - return false; - } - const seenAt = cache.get(key); - return typeof seenAt === "number" && Date.now() - seenAt <= SELF_CHAT_TTL_MS; -} - -export function resetBlueBubblesSelfChatCache(): void { - cache.clear(); - lastCleanupAt = 0; -} diff --git a/extensions/bluebubbles/src/monitor-shared.ts b/extensions/bluebubbles/src/monitor-shared.ts deleted file mode 100644 index 8b9baf794633..000000000000 --- a/extensions/bluebubbles/src/monitor-shared.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; -import type { ResolvedBlueBubblesAccount } from "./accounts.js"; -import { getBlueBubblesRuntime } from "./runtime.js"; -export { - DEFAULT_WEBHOOK_PATH, - normalizeWebhookPath, - resolveWebhookPathFromConfig, -} from "./webhook-shared.js"; - -export type BlueBubblesRuntimeEnv = { - log?: (message: string) => void; - error?: (message: string) => void; -}; - -export type BlueBubblesMonitorOptions = { - account: ResolvedBlueBubblesAccount; - config: OpenClawConfig; - runtime: BlueBubblesRuntimeEnv; - abortSignal: AbortSignal; - statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; - webhookPath?: string; -}; - -export type BlueBubblesCoreRuntime = ReturnType; - -export type WebhookTarget = { - account: ResolvedBlueBubblesAccount; - config: OpenClawConfig; - runtime: BlueBubblesRuntimeEnv; - core: BlueBubblesCoreRuntime; - path: string; - statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; -}; diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts deleted file mode 100644 index f02eb12b6f46..000000000000 --- a/extensions/bluebubbles/src/monitor.test.ts +++ /dev/null @@ -1,2800 +0,0 @@ -import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ResolvedBlueBubblesAccount } from "./accounts.js"; -import { fetchBlueBubblesHistory } from "./history.js"; -import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js"; -import type { NormalizedWebhookMessage } from "./monitor-normalize.js"; -import { resetBlueBubblesSelfChatCache } from "./monitor-self-chat-cache.js"; -import { resolveBlueBubblesMessageId } from "./monitor.js"; -import { - createMockAccount, - createMockRequest, - createNewMessagePayloadForTest, - createTimestampedMessageReactionPayloadForTest, - createTimestampedNewMessagePayloadForTest, - dispatchWebhookPayloadForTest, - dispatchWebhookRequestForTest, - setupWebhookTargetForTest, - setupWebhookTargetsForTest, - trackWebhookRegistrationForTest, -} from "./monitor.webhook.test-helpers.js"; -import { - resetBlueBubblesParticipantContactNameCacheForTest, - setBlueBubblesParticipantContactDepsForTest, -} from "./participant-contact-names.js"; -import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js"; -import { createBlueBubblesFetchGuardPassthroughInstaller } from "./test-harness.js"; -import { - createBlueBubblesMonitorTestRuntime, - EMPTY_DISPATCH_RESULT, - resetBlueBubblesMonitorTestState, - type DispatchReplyParams, -} from "./test-support/monitor-test-support.js"; -import { _setFetchGuardForTesting } from "./types.js"; - -// Mock dependencies -vi.mock("./send.js", () => ({ - resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"), - sendMessageBlueBubbles: vi.fn().mockResolvedValue({ - messageId: "msg-123", - receipt: { - primaryPlatformMessageId: "msg-123", - platformMessageIds: ["msg-123"], - parts: [], - sentAt: 0, - raw: [], - }, - }), -})); - -vi.mock("./chat.js", () => ({ - markBlueBubblesChatRead: vi.fn().mockResolvedValue(undefined), - sendBlueBubblesTyping: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("./attachments.js", () => ({ - downloadBlueBubblesAttachment: vi.fn().mockResolvedValue({ - buffer: Buffer.from("test"), - contentType: "image/jpeg", - }), -})); - -vi.mock("./reactions.js", async () => { - const actual = await vi.importActual("./reactions.js"); - return { - ...actual, - sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined), - }; -}); - -vi.mock("./history.js", () => ({ - fetchBlueBubblesHistory: vi.fn().mockResolvedValue({ entries: [], resolved: true }), -})); - -afterAll(() => { - vi.doUnmock("./send.js"); - vi.doUnmock("./chat.js"); - vi.doUnmock("./attachments.js"); - vi.doUnmock("./reactions.js"); - vi.doUnmock("./history.js"); - vi.resetModules(); -}); - -// Mock runtime -const mockEnqueueSystemEvent = vi.fn(); -const mockBuildPairingReply = vi.fn(() => "Pairing code: TESTCODE"); -const mockReadAllowFromStore = vi.fn().mockResolvedValue([]); -const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "TESTCODE", created: true }); -const DEFAULT_RESOLVED_AGENT_ROUTE: ReturnType< - PluginRuntime["channel"]["routing"]["resolveAgentRoute"] -> = { - agentId: "main", - channel: "bluebubbles", - accountId: "default", - sessionKey: "agent:main:bluebubbles:dm:+15551234567", - mainSessionKey: "agent:main:main", - lastRoutePolicy: "main", - matchedBy: "default", -}; -const mockResolveAgentRoute = vi.fn(() => DEFAULT_RESOLVED_AGENT_ROUTE); - -function blueBubblesTestSendResult(messageId: string) { - const hasPlatformId = messageId && messageId !== "ok" && messageId !== "unknown"; - return { - messageId, - receipt: { - ...(hasPlatformId ? { primaryPlatformMessageId: messageId } : {}), - platformMessageIds: hasPlatformId ? [messageId] : [], - parts: [], - sentAt: 0, - raw: [], - }, - }; -} -const mockBuildMentionRegexes = vi.fn(() => [/\bbert\b/i]); -const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) => - regexes.some((r) => r.test(text)), -); -const mockMatchesMentionWithExplicit = vi.fn( - (params: { text: string; mentionRegexes: RegExp[]; explicitWasMentioned?: boolean }) => { - if (params.explicitWasMentioned) { - return true; - } - return params.mentionRegexes.some((regex) => regex.test(params.text)); - }, -); -const mockResolveRequireMention = vi.fn(() => false); -const mockResolveGroupPolicy = vi.fn(() => ({ - allowlistEnabled: false, - allowed: true, -})); -const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn( - async (_params: DispatchReplyParams) => EMPTY_DISPATCH_RESULT, -); -const mockHasControlCommand = vi.fn(() => false); -const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false); -const mockSaveMediaBuffer = vi.fn().mockResolvedValue({ - id: "test-media.jpg", - path: "/tmp/test-media.jpg", - size: Buffer.byteLength("test"), - contentType: "image/jpeg", -}); -const mockResolveStorePath = vi.fn(() => "/tmp/sessions.json"); -const mockReadSessionUpdatedAt = vi.fn(() => undefined); -const mockResolveEnvelopeFormatOptions = vi.fn(() => ({})); -const mockFormatAgentEnvelope = vi.fn((opts: { body: string }) => opts.body); -const mockFormatInboundEnvelope = vi.fn((opts: { body: string }) => opts.body); -const mockChunkMarkdownText = vi.fn((text: string) => [text]); -const mockChunkByNewline = vi.fn((text: string) => (text ? [text] : [])); -const mockChunkTextWithMode = vi.fn((text: string) => (text ? [text] : [])); -const mockChunkMarkdownTextWithMode = vi.fn((text: string) => (text ? [text] : [])); -const mockResolveChunkMode = vi.fn(() => "length" as const); -const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory); -const mockFetch = vi.fn(); - -function createMockRuntime(): PluginRuntime { - return createBlueBubblesMonitorTestRuntime({ - enqueueSystemEvent: mockEnqueueSystemEvent, - chunkMarkdownText: mockChunkMarkdownText, - chunkByNewline: mockChunkByNewline, - chunkMarkdownTextWithMode: mockChunkMarkdownTextWithMode, - chunkTextWithMode: mockChunkTextWithMode, - resolveChunkMode: mockResolveChunkMode, - hasControlCommand: mockHasControlCommand, - dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher, - formatAgentEnvelope: mockFormatAgentEnvelope, - formatInboundEnvelope: mockFormatInboundEnvelope, - resolveEnvelopeFormatOptions: mockResolveEnvelopeFormatOptions, - resolveAgentRoute: mockResolveAgentRoute, - buildPairingReply: mockBuildPairingReply, - readAllowFromStore: mockReadAllowFromStore, - upsertPairingRequest: mockUpsertPairingRequest, - saveMediaBuffer: mockSaveMediaBuffer, - resolveStorePath: mockResolveStorePath, - readSessionUpdatedAt: mockReadSessionUpdatedAt, - buildMentionRegexes: mockBuildMentionRegexes, - matchesMentionPatterns: mockMatchesMentionPatterns, - matchesMentionWithExplicit: mockMatchesMentionWithExplicit, - resolveGroupPolicy: mockResolveGroupPolicy, - resolveRequireMention: mockResolveRequireMention, - resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers, - }); -} - -function getFirstDispatchCall(): DispatchReplyParams { - const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0]; - if (!callArgs) { - throw new Error("expected dispatch call arguments"); - } - return callArgs; -} - -function installTimingAwareInboundDebouncer(core: PluginRuntime) { - // Use a timing-aware debouncer test double that respects debounceMs/buildKey/shouldDebounce. - core.channel.debounce.createInboundDebouncer = vi.fn((params: any) => { - type Item = any; - const buckets = new Map< - string, - { items: Item[]; timer: ReturnType | null } - >(); - - const flush = async (key: string) => { - const bucket = buckets.get(key); - if (!bucket) { - return; - } - if (bucket.timer) { - clearTimeout(bucket.timer); - bucket.timer = null; - } - const items = bucket.items; - bucket.items = []; - if (items.length > 0) { - try { - await params.onFlush(items); - } catch (err) { - params.onError?.(err); - throw err; - } - } - }; - - return { - enqueue: async (item: Item) => { - if (params.shouldDebounce && !params.shouldDebounce(item)) { - await params.onFlush([item]); - return; - } - - const key = params.buildKey(item); - const existing = buckets.get(key); - const bucket = existing ?? { items: [], timer: null }; - bucket.items.push(item); - if (bucket.timer) { - clearTimeout(bucket.timer); - } - bucket.timer = setTimeout(async () => { - await flush(key); - }, params.debounceMs); - buckets.set(key, bucket); - }, - flushKey: vi.fn(async (key: string) => { - await flush(key); - }), - }; - }) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"]; -} - -function createDebounceTestMessage( - overrides: Partial = {}, -): NormalizedWebhookMessage { - return { - text: "hello", - senderId: "+15551234567", - senderIdExplicit: true, - isGroup: false, - ...overrides, - }; -} - -describe("BlueBubbles webhook monitor", () => { - let unregister: () => void; - - function setupWebhookTarget(params?: { - account?: ReturnType; - config?: OpenClawConfig; - core?: PluginRuntime; - }) { - const registration = trackWebhookRegistrationForTest( - setupWebhookTargetForTest({ - createCore: createMockRuntime, - core: params?.core, - account: params?.account, - config: params?.config, - }), - (nextUnregister) => { - unregister = nextUnregister; - }, - ); - return { core: registration.core }; - } - - async function dispatchWebhookPayload(payload: unknown, url = "/bluebubbles-webhook") { - return (await dispatchWebhookPayloadForTest({ body: payload, url })).res; - } - - async function dispatchWebhookPayloadDirect(payload: unknown, url = "/bluebubbles-webhook") { - const { handled } = await dispatchWebhookRequestForTest( - createMockRequest("POST", url, payload), - ); - return handled; - } - - const installFetchGuardPassthrough = createBlueBubblesFetchGuardPassthroughInstaller(); - - beforeEach(() => { - vi.stubGlobal("fetch", mockFetch); - // The BlueBubblesClient now routes every BB API call through the SSRF - // guard (mode-2 allowlist for configured hostnames). Install a passthrough - // that wraps `globalThis.fetch` (our stubbed mockFetch) in a real Response - // so guarded callers get the same mocked behavior the pre-migration - // callsites did. (#34749, #59722) - installFetchGuardPassthrough(); - mockFetch.mockReset(); - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }); - resetBlueBubblesMonitorTestState({ - createRuntime: createMockRuntime, - fetchHistoryMock: mockFetchBlueBubblesHistory, - readAllowFromStoreMock: mockReadAllowFromStore, - upsertPairingRequestMock: mockUpsertPairingRequest, - resolveRequireMentionMock: mockResolveRequireMention, - hasControlCommandMock: mockHasControlCommand, - resolveCommandAuthorizedFromAuthorizersMock: mockResolveCommandAuthorizedFromAuthorizers, - buildMentionRegexesMock: mockBuildMentionRegexes, - extraReset: () => { - resetBlueBubblesSelfChatCache(); - resetBlueBubblesParticipantContactNameCacheForTest(); - setBlueBubblesParticipantContactDepsForTest(); - }, - }); - }); - - afterEach(() => { - unregister?.(); - setBlueBubblesParticipantContactDepsForTest(); - vi.useRealTimers(); - vi.unstubAllGlobals(); - _setFetchGuardForTesting(null); - }); - - describe("DM pairing behavior vs allowFrom", () => { - it("allows DM from sender in allowFrom list", async () => { - setupWebhookTarget({ - account: createMockAccount({ - dmPolicy: "allowlist", - allowFrom: ["+15551234567"], - }), - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "hello from allowed sender", - }); - - const res = await dispatchWebhookPayload(payload); - - expect(res.statusCode).toBe(200); - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); - }); - - it("blocks DM from sender not in allowFrom when dmPolicy=allowlist", async () => { - setupWebhookTarget({ - account: createMockAccount({ - dmPolicy: "allowlist", - allowFrom: ["+15559999999"], // Different number - }), - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "hello from blocked sender", - }); - - const res = await dispatchWebhookPayload(payload); - - expect(res.statusCode).toBe(200); - expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("blocks DM when dmPolicy=allowlist and allowFrom is empty", async () => { - setupWebhookTarget({ - account: createMockAccount({ - dmPolicy: "allowlist", - allowFrom: [], - }), - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "hello from blocked sender", - }); - - const res = await dispatchWebhookPayload(payload); - - expect(res.statusCode).toBe(200); - expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - expect(mockUpsertPairingRequest).not.toHaveBeenCalled(); - }); - - it("triggers pairing flow for unknown sender when dmPolicy=pairing and allowFrom is empty", async () => { - setupWebhookTarget({ - account: createMockAccount({ - dmPolicy: "pairing", - allowFrom: [], - }), - }); - - const payload = createTimestampedNewMessagePayloadForTest(); - - await dispatchWebhookPayload(payload); - - expect(mockUpsertPairingRequest).toHaveBeenCalled(); - expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("triggers pairing flow for unknown sender when dmPolicy=pairing", async () => { - setupWebhookTarget({ - account: createMockAccount({ - dmPolicy: "pairing", - allowFrom: ["+15559999999"], // Different number than sender - }), - }); - - const payload = createTimestampedNewMessagePayloadForTest(); - - await dispatchWebhookPayload(payload); - - expect(mockUpsertPairingRequest).toHaveBeenCalled(); - expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("does not resend pairing reply when request already exists", async () => { - mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: false }); - - setupWebhookTarget({ - account: createMockAccount({ - dmPolicy: "pairing", - allowFrom: ["+15559999999"], // Different number than sender - }), - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "hello again", - guid: "msg-2", - }); - - await dispatchWebhookPayload(payload); - - expect(mockUpsertPairingRequest).toHaveBeenCalled(); - // Should not send pairing reply since created=false - const { sendMessageBlueBubbles } = await import("./send.js"); - expect(sendMessageBlueBubbles).not.toHaveBeenCalled(); - }); - - it("allows wildcard DMs when dmPolicy=open", async () => { - setupWebhookTarget({ - account: createMockAccount({ - dmPolicy: "open", - allowFrom: ["*"], - }), - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "hello from anyone", - handle: { address: "+15559999999" }, - }); - - await dispatchWebhookPayload(payload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); - }); - - it("blocks all DMs when dmPolicy=disabled", async () => { - setupWebhookTarget({ - account: createMockAccount({ - dmPolicy: "disabled", - }), - }); - - const payload = createTimestampedNewMessagePayloadForTest(); - - await dispatchWebhookPayload(payload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - }); - - describe("group message gating", () => { - it("allows group messages when groupPolicy=open and no allowlist", async () => { - setupWebhookTarget({ - account: createMockAccount({ - groupPolicy: "open", - }), - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "hello from group", - isGroup: true, - chatGuid: "iMessage;+;chat123456", - }); - - await dispatchWebhookPayload(payload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); - }); - - it("blocks group messages when groupPolicy=disabled", async () => { - setupWebhookTarget({ - account: createMockAccount({ - groupPolicy: "disabled", - }), - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "hello from group", - isGroup: true, - chatGuid: "iMessage;+;chat123456", - }); - - await dispatchWebhookPayload(payload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("treats chat_guid groups as group even when isGroup=false", async () => { - setupWebhookTarget({ - account: createMockAccount({ - groupPolicy: "allowlist", - dmPolicy: "open", - allowFrom: [], - }), - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "hello from group", - chatGuid: "iMessage;+;chat123456", - }); - - await dispatchWebhookPayload(payload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("allows group messages from allowed chat_guid in groupAllowFrom", async () => { - setupWebhookTarget({ - account: createMockAccount({ - groupPolicy: "allowlist", - groupAllowFrom: ["chat_guid:iMessage;+;chat123456"], - }), - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "hello from allowed group", - isGroup: true, - chatGuid: "iMessage;+;chat123456", - }); - - await dispatchWebhookPayload(payload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); - }); - }); - - describe("mention gating (group messages)", () => { - it("processes group message when mentioned and requireMention=true", async () => { - mockResolveRequireMention.mockReturnValue(true); - mockMatchesMentionPatterns.mockReturnValue(true); - - setupWebhookTarget(); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "bert, can you help me?", - isGroup: true, - chatGuid: "iMessage;+;chat123456", - }); - - await dispatchWebhookPayload(payload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); - const callArgs = getFirstDispatchCall(); - expect(callArgs.ctx.WasMentioned).toBe(true); - }); - - it("skips group message when not mentioned and requireMention=true", async () => { - mockResolveRequireMention.mockReturnValue(true); - mockMatchesMentionPatterns.mockReturnValue(false); - - setupWebhookTarget(); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "hello everyone", - isGroup: true, - chatGuid: "iMessage;+;chat123456", - }); - - await dispatchWebhookPayload(payload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("processes group message without mention when requireMention=false", async () => { - mockResolveRequireMention.mockReturnValue(false); - - setupWebhookTarget(); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "hello everyone", - isGroup: true, - chatGuid: "iMessage;+;chat123456", - }); - - await dispatchWebhookPayload(payload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); - }); - }); - - describe("group metadata", () => { - it("includes group subject + members in ctx", async () => { - setupWebhookTarget(); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "hello group", - isGroup: true, - chatGuid: "iMessage;+;chat123456", - chatName: "Family", - participants: [ - { address: "+15551234567", displayName: "Alice" }, - { address: "+15557654321", displayName: "Bob" }, - ], - }); - - await dispatchWebhookPayload(payload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); - const callArgs = getFirstDispatchCall(); - expect(callArgs.ctx.GroupSubject).toBe("Family"); - expect(callArgs.ctx.GroupMembers).toBe("Alice (+15551234567), Bob (+15557654321)"); - }); - - it("threads per-group systemPrompt into ctx for group messages", async () => { - setupWebhookTarget({ - account: createMockAccount({ - groups: { - "iMessage;+;chat123456": { - systemPrompt: "Reply in thread with action=reply; ack via action=react.", - }, - }, - }), - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "hello group", - isGroup: true, - chatGuid: "iMessage;+;chat123456", - chatName: "Family", - participants: [{ address: "+15551234567", displayName: "Alice" }], - }); - - await dispatchWebhookPayload(payload); - - const callArgs = getFirstDispatchCall(); - expect(callArgs.ctx.GroupSystemPrompt).toBe( - "Reply in thread with action=reply; ack via action=react.", - ); - }); - - it("falls back to the '*' wildcard systemPrompt when no exact group match", async () => { - setupWebhookTarget({ - account: createMockAccount({ - groups: { - "*": { systemPrompt: "Default group rule: keep it short." }, - }, - }), - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "hi group", - isGroup: true, - chatGuid: "iMessage;+;chat-unmapped", - chatName: "Family", - participants: [{ address: "+15551234567", displayName: "Alice" }], - }); - - await dispatchWebhookPayload(payload); - - const callArgs = getFirstDispatchCall(); - expect(callArgs.ctx.GroupSystemPrompt).toBe("Default group rule: keep it short."); - }); - - it("prefers an exact group systemPrompt over the '*' wildcard", async () => { - setupWebhookTarget({ - account: createMockAccount({ - groups: { - "*": { systemPrompt: "wildcard value" }, - "iMessage;+;chat123456": { systemPrompt: "exact value" }, - }, - }), - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "hi group", - isGroup: true, - chatGuid: "iMessage;+;chat123456", - chatName: "Family", - participants: [{ address: "+15551234567", displayName: "Alice" }], - }); - - await dispatchWebhookPayload(payload); - - const callArgs = getFirstDispatchCall(); - expect(callArgs.ctx.GroupSystemPrompt).toBe("exact value"); - }); - - it("omits GroupSystemPrompt for DMs even when the group config would match", async () => { - setupWebhookTarget({ - account: createMockAccount({ - groups: { - "+15551234567": { systemPrompt: "unused in DM" }, - }, - }), - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "hi", - isGroup: false, - }); - - await dispatchWebhookPayload(payload); - - const callArgs = getFirstDispatchCall(); - expect(callArgs.ctx.GroupSystemPrompt).toBeUndefined(); - }); - - it("does not enrich group participants when the config flag is disabled", async () => { - const resolvePhoneNames = vi.fn(async () => new Map([["5551234567", "Alice Contact"]])); - setupWebhookTarget({ - account: createMockAccount({ - enrichGroupParticipantsFromContacts: false, - }), - }); - setBlueBubblesParticipantContactDepsForTest({ - platform: "darwin", - resolvePhoneNames, - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "hello bert", - isGroup: true, - chatGuid: "iMessage;+;chat123456", - chatName: "Family", - participants: [{ address: "+15551234567" }], - }); - - await dispatchWebhookPayload(payload); - - expect(resolvePhoneNames).not.toHaveBeenCalled(); - expect(getFirstDispatchCall().ctx.GroupMembers).toBe("+15551234567"); - }); - - it("enriches unnamed phone participants from local contacts after gating passes", async () => { - const resolvePhoneNames = vi.fn( - async (phoneKeys: string[]) => - new Map( - phoneKeys.map((phoneKey) => [ - phoneKey, - phoneKey === "5551234567" ? "Alice Contact" : "Bob Contact", - ]), - ), - ); - setupWebhookTarget({ - account: createMockAccount({ - enrichGroupParticipantsFromContacts: true, - }), - }); - setBlueBubblesParticipantContactDepsForTest({ - platform: "darwin", - resolvePhoneNames, - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "hello bert", - isGroup: true, - chatGuid: "iMessage;+;chat123456", - chatName: "Family", - participants: [{ address: "+15551234567" }, { address: "+15557654321" }], - }); - - await dispatchWebhookPayload(payload); - - expect(resolvePhoneNames).toHaveBeenCalledTimes(1); - const callArgs = getFirstDispatchCall(); - expect(callArgs.ctx.GroupMembers).toBe( - "Alice Contact (+15551234567), Bob Contact (+15557654321)", - ); - }); - - it("fetches missing group participants from the BlueBubbles API before contact enrichment", async () => { - const resolvePhoneNames = vi.fn( - async (phoneKeys: string[]) => - new Map( - phoneKeys.map((phoneKey) => [ - phoneKey, - phoneKey === "5551234567" ? "Alice Contact" : "Bob Contact", - ]), - ), - ); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "iMessage;+;chat123456", - participants: [{ address: "+15551234567" }, { address: "+15557654321" }], - }, - ], - }), - }); - setupWebhookTarget({ - account: createMockAccount({ - enrichGroupParticipantsFromContacts: true, - }), - }); - setBlueBubblesParticipantContactDepsForTest({ - platform: "darwin", - resolvePhoneNames, - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "hello bert", - isGroup: true, - chatGuid: "iMessage;+;chat123456", - chatName: "Family", - }); - - await dispatchWebhookPayload(payload); - - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining("/api/v1/chat/query"), - expect.objectContaining({ method: "POST" }), - ); - expect(resolvePhoneNames).toHaveBeenCalledTimes(1); - expect(getFirstDispatchCall().ctx.GroupMembers).toBe( - "Alice Contact (+15551234567), Bob Contact (+15557654321)", - ); - }); - - it("does not read local contacts before mention gating allows the message", async () => { - const resolvePhoneNames = vi.fn(async () => new Map([["5551234567", "Alice Contact"]])); - setupWebhookTarget({ - account: createMockAccount({ - enrichGroupParticipantsFromContacts: true, - }), - }); - setBlueBubblesParticipantContactDepsForTest({ - platform: "darwin", - resolvePhoneNames, - }); - mockResolveRequireMention.mockReturnValueOnce(true); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "hello group", - isGroup: true, - chatGuid: "iMessage;+;chat123456", - chatName: "Family", - participants: [{ address: "+15551234567" }], - }); - - await dispatchWebhookPayload(payload); - - expect(resolvePhoneNames).not.toHaveBeenCalled(); - expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - }); - - describe("group sender identity in envelope", () => { - it("includes sender in envelope body and group label as from for group messages", async () => { - setupWebhookTarget(); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "hello everyone", - senderName: "Alice", - isGroup: true, - chatGuid: "iMessage;+;chat123456", - chatName: "Family Chat", - }); - - await dispatchWebhookPayload(payload); - - // formatInboundEnvelope should be called with group label + id as from, and sender info - expect(mockFormatInboundEnvelope).toHaveBeenCalledWith( - expect.objectContaining({ - from: "Family Chat id:iMessage;+;chat123456", - chatType: "group", - sender: { name: "Alice", id: "+15551234567" }, - }), - ); - // ConversationLabel should be the group label + id, not the sender - const callArgs = getFirstDispatchCall(); - expect(callArgs.ctx.ConversationLabel).toBe("Family Chat id:iMessage;+;chat123456"); - expect(callArgs.ctx.SenderName).toBe("Alice"); - // BodyForAgent should be raw text, not the envelope-formatted body - expect(callArgs.ctx.BodyForAgent).toBe("hello everyone"); - }); - - it("falls back to group:peerId when chatName is missing", async () => { - setupWebhookTarget(); - - const payload = createTimestampedNewMessagePayloadForTest({ - isGroup: true, - chatGuid: "iMessage;+;chat123456", - }); - - await dispatchWebhookPayload(payload); - - expect(mockFormatInboundEnvelope).toHaveBeenCalledWith( - expect.objectContaining({ - from: expect.stringMatching(/^Group id:/), - chatType: "group", - sender: { name: undefined, id: "+15551234567" }, - }), - ); - }); - - it("uses sender as from label for DM messages", async () => { - setupWebhookTarget(); - - const payload = createTimestampedNewMessagePayloadForTest({ - senderName: "Alice", - }); - - await dispatchWebhookPayload(payload); - - expect(mockFormatInboundEnvelope).toHaveBeenCalledWith( - expect.objectContaining({ - from: "Alice id:+15551234567", - chatType: "direct", - sender: { name: "Alice", id: "+15551234567" }, - }), - ); - const callArgs = getFirstDispatchCall(); - expect(callArgs.ctx.ConversationLabel).toBe("Alice id:+15551234567"); - }); - }); - - describe("inbound debouncing", () => { - it("coalesces text-only then attachment webhook events by messageId", async () => { - vi.useFakeTimers(); - try { - const core = createMockRuntime(); - installTimingAwareInboundDebouncer(core); - - const _registration = trackWebhookRegistrationForTest( - setupWebhookTargetForTest({ - createCore: createMockRuntime, - core, - }), - (nextUnregister) => { - unregister = nextUnregister; - }, - ); - - const messageId = "race-msg-1"; - const chatGuid = "iMessage;-;+15551234567"; - - const payloadA = createTimestampedNewMessagePayloadForTest({ - guid: messageId, - chatGuid, - }); - - const payloadB = createTimestampedNewMessagePayloadForTest({ - guid: messageId, - chatGuid, - attachments: [ - { - guid: "att-1", - mimeType: "image/jpeg", - totalBytes: 1024, - }, - ], - }); - - await dispatchWebhookPayloadDirect(payloadA); - - // Simulate the real-world delay where the attachment-bearing webhook arrives shortly after. - await vi.advanceTimersByTimeAsync(300); - - await dispatchWebhookPayloadDirect(payloadB); - - // Not flushed yet; still within the debounce window. - expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - - // After the debounce window, the combined message should be processed exactly once. - await vi.advanceTimersByTimeAsync(600); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); - const callArgs = getFirstDispatchCall(); - expect(callArgs.ctx.MediaPaths).toEqual(["/tmp/test-media.jpg"]); - expect(callArgs.ctx.Body).toContain("hello"); - } finally { - vi.useRealTimers(); - } - }); - - it("coalesces URL text with URL balloon webhook events by associatedMessageGuid", async () => { - vi.useFakeTimers(); - try { - const core = createMockRuntime(); - installTimingAwareInboundDebouncer(core); - const processMessage = vi.fn().mockResolvedValue(undefined); - const registry = createBlueBubblesDebounceRegistry({ processMessage }); - const account = createMockAccount(); - const target = { - account, - config: {}, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }; - const debouncer = registry.getOrCreateDebouncer(target); - - const messageId = "url-msg-1"; - const chatGuid = "iMessage;-;+15551234567"; - const url = "https://github.com/bitfocus/companion/issues/4047"; - - await debouncer.enqueue({ - message: createDebounceTestMessage({ - chatGuid, - text: url, - messageId, - }), - target, - }); - - await vi.advanceTimersByTimeAsync(300); - - await debouncer.enqueue({ - message: createDebounceTestMessage({ - chatGuid, - text: url, - messageId: "url-balloon-1", - balloonBundleId: "com.apple.messages.URLBalloonProvider", - associatedMessageGuid: messageId, - }), - target, - }); - - expect(processMessage).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(600); - - expect(processMessage).toHaveBeenCalledTimes(1); - expect(processMessage).toHaveBeenCalledWith( - expect.objectContaining({ - text: url, - messageId, - balloonBundleId: undefined, - }), - target, - ); - expect(target.runtime.error).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - }); - - it("coalesces same-sender DM messages when coalesceSameSenderDms is enabled", async () => { - vi.useFakeTimers(); - try { - const core = createMockRuntime(); - installTimingAwareInboundDebouncer(core); - const processMessage = vi.fn().mockResolvedValue(undefined); - const registry = createBlueBubblesDebounceRegistry({ processMessage }); - const account = createMockAccount({ coalesceSameSenderDms: true }); - const target = { - account, - // Pin an explicit short debounce window so these tests stay - // decoupled from the coalesce-flag default (2500 ms). The - // "widens the default debounce window" test intentionally omits - // this override to exercise the new default. - config: { messages: { inbound: { byChannel: { bluebubbles: 500 } } } }, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }; - const debouncer = registry.getOrCreateDebouncer(target); - - const chatGuid = "iMessage;-;+15551234567"; - - // Two distinct user sends: a command ("Dump") followed by a URL. - // No associatedMessageGuid linking them. Default buildKey hashes by - // per-message messageId, so historically they dispatched separately. - await debouncer.enqueue({ - message: createDebounceTestMessage({ - chatGuid, - text: "Dump", - messageId: "dm-msg-1", - }), - target, - }); - - await vi.advanceTimersByTimeAsync(300); - - await debouncer.enqueue({ - message: createDebounceTestMessage({ - chatGuid, - text: "https://example.com/article", - messageId: "dm-msg-2", - }), - target, - }); - - expect(processMessage).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(600); - - expect(processMessage).toHaveBeenCalledTimes(1); - expect(processMessage).toHaveBeenCalledWith( - expect.objectContaining({ - text: "Dump https://example.com/article", - // Every source messageId must reach inbound-dedupe so a later - // MessagePoller replay of either event alone is recognized as a - // duplicate rather than re-processed. - coalescedMessageIds: ["dm-msg-1", "dm-msg-2"], - }), - target, - ); - expect(target.runtime.error).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - }); - - it("does not coalesce same-sender DM messages when coalesceSameSenderDms is off (default)", async () => { - vi.useFakeTimers(); - try { - const core = createMockRuntime(); - installTimingAwareInboundDebouncer(core); - const processMessage = vi.fn().mockResolvedValue(undefined); - const registry = createBlueBubblesDebounceRegistry({ processMessage }); - const account = createMockAccount(); - const target = { - account, - config: {}, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }; - const debouncer = registry.getOrCreateDebouncer(target); - - const chatGuid = "iMessage;-;+15551234567"; - - await debouncer.enqueue({ - message: createDebounceTestMessage({ - chatGuid, - text: "Dump", - messageId: "dm-msg-1", - }), - target, - }); - - await vi.advanceTimersByTimeAsync(300); - - await debouncer.enqueue({ - message: createDebounceTestMessage({ - chatGuid, - text: "https://example.com/article", - messageId: "dm-msg-2", - }), - target, - }); - - await vi.advanceTimersByTimeAsync(600); - - expect(processMessage).toHaveBeenCalledTimes(2); - } finally { - vi.useRealTimers(); - } - }); - - it("bounds the coalesced output when many messages merge into one turn", async () => { - vi.useFakeTimers(); - try { - const core = createMockRuntime(); - installTimingAwareInboundDebouncer(core); - const processMessage = vi.fn().mockResolvedValue(undefined); - const registry = createBlueBubblesDebounceRegistry({ processMessage }); - const account = createMockAccount({ coalesceSameSenderDms: true }); - const target = { - account, - // Pin an explicit short debounce window so these tests stay - // decoupled from the coalesce-flag default (2500 ms). The - // "widens the default debounce window" test intentionally omits - // this override to exercise the new default. - config: { messages: { inbound: { byChannel: { bluebubbles: 500 } } } }, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }; - const debouncer = registry.getOrCreateDebouncer(target); - - const chatGuid = "iMessage;-;+15551234567"; - // Use a unique long text block per entry to exceed MAX_COALESCED_TEXT_CHARS (4000) - // after naive concatenation. 25 entries × ~400 chars ≈ 10_000 chars worth of content. - const blob = "x".repeat(400); - for (let i = 0; i < 25; i++) { - await debouncer.enqueue({ - message: createDebounceTestMessage({ - chatGuid, - text: `msg-${i}-${blob}`, - messageId: `flood-${i}`, - attachments: [{ guid: `att-${i}`, mimeType: "image/jpeg", totalBytes: 1024 }], - }), - target, - }); - await vi.advanceTimersByTimeAsync(10); - } - - await vi.advanceTimersByTimeAsync(600); - - expect(processMessage).toHaveBeenCalledTimes(1); - const [merged] = processMessage.mock.calls[0] as [NormalizedWebhookMessage, unknown]; - // Text is truncated with explicit marker instead of ballooning. - expect(merged.text.length).toBeLessThanOrEqual(4000 + "…[truncated]".length); - expect(merged.text.endsWith("…[truncated]")).toBe(true); - // Attachments are capped so downstream media fan-out stays bounded. - expect(merged.attachments?.length).toBeLessThanOrEqual(20); - // Every source messageId — including ones whose text/attachments the - // cap dropped — still reaches inbound-dedupe. Truncation caps prompt - // size; it must not leak replay risk. - expect(merged.coalescedMessageIds).toHaveLength(25); - expect(merged.coalescedMessageIds?.[0]).toBe("flood-0"); - expect(merged.coalescedMessageIds?.[24]).toBe("flood-24"); - } finally { - vi.useRealTimers(); - } - }); - - it("does not coalesce group-chat messages even with coalesceSameSenderDms enabled", async () => { - vi.useFakeTimers(); - try { - const core = createMockRuntime(); - installTimingAwareInboundDebouncer(core); - const processMessage = vi.fn().mockResolvedValue(undefined); - const registry = createBlueBubblesDebounceRegistry({ processMessage }); - const account = createMockAccount({ coalesceSameSenderDms: true }); - const target = { - account, - // Pin an explicit short debounce window so these tests stay - // decoupled from the coalesce-flag default (2500 ms). The - // "widens the default debounce window" test intentionally omits - // this override to exercise the new default. - config: { messages: { inbound: { byChannel: { bluebubbles: 500 } } } }, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }; - const debouncer = registry.getOrCreateDebouncer(target); - - const chatGuid = "iMessage;-;group-abc"; - - await debouncer.enqueue({ - message: createDebounceTestMessage({ - chatGuid, - text: "first", - messageId: "grp-msg-1", - isGroup: true, - }), - target, - }); - - await vi.advanceTimersByTimeAsync(300); - - await debouncer.enqueue({ - message: createDebounceTestMessage({ - chatGuid, - text: "second", - messageId: "grp-msg-2", - isGroup: true, - }), - target, - }); - - await vi.advanceTimersByTimeAsync(600); - - expect(processMessage).toHaveBeenCalledTimes(2); - } finally { - vi.useRealTimers(); - } - }); - - it("debounces DM control commands when coalesceSameSenderDms is on so a split-send URL can join the bucket", async () => { - vi.useFakeTimers(); - try { - const core = createMockRuntime(); - installTimingAwareInboundDebouncer(core); - const processMessage = vi.fn().mockResolvedValue(undefined); - const registry = createBlueBubblesDebounceRegistry({ processMessage }); - const account = createMockAccount({ coalesceSameSenderDms: true }); - const target = { - account, - // Pin an explicit short debounce window so these tests stay - // decoupled from the coalesce-flag default (2500 ms). The - // "widens the default debounce window" test intentionally omits - // this override to exercise the new default. - config: { messages: { inbound: { byChannel: { bluebubbles: 500 } } } }, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }; - const debouncer = registry.getOrCreateDebouncer(target); - - const chatGuid = "iMessage;-;+15551234567"; - // Simulate a registered skill alias ("Dump") — normally this would - // flush immediately and miss its split-send URL. The coalesce flag - // must override that short-circuit for DMs specifically. - mockHasControlCommand.mockReturnValue(true); - - await debouncer.enqueue({ - message: createDebounceTestMessage({ - chatGuid, - text: "Dump", - messageId: "cmd-msg-1", - }), - target, - }); - - // Apple/BlueBubbles delivers the URL ~750 ms later — well inside a - // reasonable coalesce window. - await vi.advanceTimersByTimeAsync(300); - expect(processMessage).not.toHaveBeenCalled(); - - await debouncer.enqueue({ - message: createDebounceTestMessage({ - chatGuid, - text: "https://example.com/article", - messageId: "cmd-msg-2", - }), - target, - }); - - await vi.advanceTimersByTimeAsync(600); - - expect(processMessage).toHaveBeenCalledTimes(1); - expect(processMessage).toHaveBeenCalledWith( - expect.objectContaining({ - text: "Dump https://example.com/article", - coalescedMessageIds: ["cmd-msg-1", "cmd-msg-2"], - }), - target, - ); - } finally { - vi.useRealTimers(); - mockHasControlCommand.mockReturnValue(false); - } - }); - - it("coalesces an orphan URL-balloon with a preceding DM control command (Apple split-send)", async () => { - vi.useFakeTimers(); - try { - const core = createMockRuntime(); - installTimingAwareInboundDebouncer(core); - const processMessage = vi.fn().mockResolvedValue(undefined); - const registry = createBlueBubblesDebounceRegistry({ processMessage }); - const account = createMockAccount({ coalesceSameSenderDms: true }); - const target = { - account, - // Pin an explicit short debounce window so these tests stay - // decoupled from the coalesce-flag default (2500 ms). The - // "widens the default debounce window" test intentionally omits - // this override to exercise the new default. - config: { messages: { inbound: { byChannel: { bluebubbles: 500 } } } }, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }; - const debouncer = registry.getOrCreateDebouncer(target); - - const chatGuid = "iMessage;-;+15551234567"; - mockHasControlCommand.mockReturnValue(true); - - // Matches the live trace from BB server: - // 20:45:13.232 New Message "Dump" - // 20:45:14.274 New Message "https://..."; Attachments: 3 - // The second webhook arrives with balloonBundleId set (URL-preview - // balloon) but no associatedMessageGuid linking it back to "Dump" — - // this is Apple's orphan split-send. buildKey must still place it in - // the same dm:: bucket as the "Dump" event. - await debouncer.enqueue({ - message: createDebounceTestMessage({ - chatGuid, - text: "Dump", - messageId: "split-cmd", - }), - target, - }); - - // Stay inside the default 500 ms window so the bucket is still open - // when the URL-balloon arrives. Real traffic needs `messages.inbound.byChannel.bluebubbles` - // bumped to ~2500 ms for the observed ~800-1800 ms Apple split-send - // cadence; this unit test keeps the default window and just proves - // the key/shouldDebounce logic buckets both webhooks together. - await vi.advanceTimersByTimeAsync(300); - - // The URL-balloon's text is not a registered command, so the mock - // must return false for that one call. - mockHasControlCommand.mockReturnValueOnce(false); - await debouncer.enqueue({ - message: createDebounceTestMessage({ - chatGuid, - text: "https://www.theverge.com/tech/906873/sofa-app-track-tv-movies-installer", - messageId: "split-url", - balloonBundleId: "com.apple.messages.URLBalloonProvider", - }), - target, - }); - - await vi.advanceTimersByTimeAsync(600); - - expect(processMessage).toHaveBeenCalledTimes(1); - expect(processMessage).toHaveBeenCalledWith( - expect.objectContaining({ - text: "Dump https://www.theverge.com/tech/906873/sofa-app-track-tv-movies-installer", - coalescedMessageIds: ["split-cmd", "split-url"], - }), - target, - ); - } finally { - vi.useRealTimers(); - mockHasControlCommand.mockReturnValue(false); - } - }); - - it("widens the default debounce window when coalesceSameSenderDms is enabled without explicit config", async () => { - vi.useFakeTimers(); - try { - const core = createMockRuntime(); - installTimingAwareInboundDebouncer(core); - const processMessage = vi.fn().mockResolvedValue(undefined); - const registry = createBlueBubblesDebounceRegistry({ processMessage }); - const account = createMockAccount({ coalesceSameSenderDms: true }); - const target = { - account, - // Intentionally NO messages.inbound.byChannel.bluebubbles — - // this test exercises the coalesce-flag default (2500 ms). - config: {}, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }; - const debouncer = registry.getOrCreateDebouncer(target); - - const chatGuid = "iMessage;-;+15551234567"; - - await debouncer.enqueue({ - message: createDebounceTestMessage({ - chatGuid, - text: "first", - messageId: "wide-1", - }), - target, - }); - - // 1500 ms is well outside the legacy 500 ms default but inside the - // 2500 ms coalesce default — without the new default, the first - // entry would flush alone before the second enqueue arrives. - await vi.advanceTimersByTimeAsync(1500); - expect(processMessage).not.toHaveBeenCalled(); - - await debouncer.enqueue({ - message: createDebounceTestMessage({ - chatGuid, - text: "second", - messageId: "wide-2", - }), - target, - }); - - await vi.advanceTimersByTimeAsync(3000); - - expect(processMessage).toHaveBeenCalledTimes(1); - expect(processMessage).toHaveBeenCalledWith( - expect.objectContaining({ - text: "first second", - coalescedMessageIds: ["wide-1", "wide-2"], - }), - target, - ); - } finally { - vi.useRealTimers(); - } - }); - - it("keeps the legacy 500 ms default window when coalesceSameSenderDms is off", async () => { - vi.useFakeTimers(); - try { - const core = createMockRuntime(); - installTimingAwareInboundDebouncer(core); - const processMessage = vi.fn().mockResolvedValue(undefined); - const registry = createBlueBubblesDebounceRegistry({ processMessage }); - const account = createMockAccount(); // flag off - const target = { - account, - config: {}, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }; - const debouncer = registry.getOrCreateDebouncer(target); - - await debouncer.enqueue({ - message: createDebounceTestMessage({ - chatGuid: "iMessage;-;+15551234567", - text: "only", - messageId: "legacy-1", - }), - target, - }); - - // Legacy behavior: flush within the tight 500 ms window so non-opt-in - // users keep their existing responsiveness. - await vi.advanceTimersByTimeAsync(600); - expect(processMessage).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } - }); - - it("keeps control commands instant for group chats even when coalesceSameSenderDms is enabled", async () => { - vi.useFakeTimers(); - try { - const core = createMockRuntime(); - installTimingAwareInboundDebouncer(core); - const processMessage = vi.fn().mockResolvedValue(undefined); - const registry = createBlueBubblesDebounceRegistry({ processMessage }); - const account = createMockAccount({ coalesceSameSenderDms: true }); - const target = { - account, - // Pin an explicit short debounce window so these tests stay - // decoupled from the coalesce-flag default (2500 ms). The - // "widens the default debounce window" test intentionally omits - // this override to exercise the new default. - config: { messages: { inbound: { byChannel: { bluebubbles: 500 } } } }, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }; - const debouncer = registry.getOrCreateDebouncer(target); - - mockHasControlCommand.mockReturnValue(true); - - await debouncer.enqueue({ - message: createDebounceTestMessage({ - chatGuid: "iMessage;-;group-xyz", - text: "Dump", - messageId: "grp-cmd-1", - isGroup: true, - }), - target, - }); - - // Group-chat command must not wait for a hypothetical bucket-mate; - // the per-message debounce key never shares anyway, so instant flush - // is the correct behavior. - expect(processMessage).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - mockHasControlCommand.mockReturnValue(false); - } - }); - - it("skips null-text entries during flush and still delivers the valid message", async () => { - vi.useFakeTimers(); - try { - const core = createMockRuntime(); - installTimingAwareInboundDebouncer(core); - - const processMessage = vi.fn().mockResolvedValue(undefined); - const registry = createBlueBubblesDebounceRegistry({ processMessage }); - const account = createMockAccount(); - const target = { - account, - config: {}, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }; - const debouncer = registry.getOrCreateDebouncer(target); - - await debouncer.enqueue({ - message: { - ...createDebounceTestMessage({ - messageId: "msg-null", - chatGuid: "iMessage;-;+15551234567", - }), - text: null, - } as unknown as NormalizedWebhookMessage, - target, - }); - - await vi.advanceTimersByTimeAsync(300); - - await debouncer.enqueue({ - message: createDebounceTestMessage({ - text: "hello from valid entry", - messageId: "msg-null", - chatGuid: "iMessage;-;+15551234567", - }), - target, - }); - - await vi.advanceTimersByTimeAsync(600); - - expect(processMessage).toHaveBeenCalledTimes(1); - expect(processMessage).toHaveBeenCalledWith( - expect.objectContaining({ - text: "hello from valid entry", - }), - target, - ); - expect(target.runtime.error).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - }); - }); - - describe("reply metadata", () => { - it("surfaces reply fields in ctx when provided", async () => { - setupWebhookTarget(); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "replying now", - chatGuid: "iMessage;-;+15551234567", - replyTo: { - guid: "msg-0", - text: "original message", - handle: { address: "+15550000000", displayName: "Alice" }, - }, - }); - - await dispatchWebhookPayload(payload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); - const callArgs = getFirstDispatchCall(); - // ReplyToId is the full UUID since it wasn't previously cached - expect(callArgs.ctx.ReplyToId).toBe("msg-0"); - expect(callArgs.ctx.ReplyToBody).toBe("original message"); - expect(callArgs.ctx.ReplyToSender).toBe("+15550000000"); - // Body uses inline [[reply_to:N]] tag format - expect(callArgs.ctx.Body).toContain("[[reply_to:msg-0]]"); - }); - - it("drops group reply context from non-allowlisted senders in allowlist mode", async () => { - setupWebhookTarget({ - account: createMockAccount({ - groupPolicy: "allowlist", - groupAllowFrom: ["+15551234567"], - }), - config: { - channels: { - bluebubbles: { - contextVisibility: "allowlist", - }, - }, - } as OpenClawConfig, - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "replying now", - isGroup: true, - chatGuid: "iMessage;+;chat-reply-visibility", - replyTo: { - guid: "msg-0", - text: "blocked context", - handle: { address: "+15550000000", displayName: "Alice" }, - }, - }); - - await dispatchWebhookPayload(payload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); - const callArgs = getFirstDispatchCall(); - expect(callArgs.ctx.ReplyToId).toBeUndefined(); - expect(callArgs.ctx.ReplyToIdFull).toBeUndefined(); - expect(callArgs.ctx.ReplyToBody).toBeUndefined(); - expect(callArgs.ctx.ReplyToSender).toBeUndefined(); - expect(callArgs.ctx.Body).not.toContain("[[reply_to:"); - }); - - it("keeps group reply context in allowlist_quote mode", async () => { - setupWebhookTarget({ - account: createMockAccount({ - groupPolicy: "allowlist", - groupAllowFrom: ["+15551234567"], - }), - config: { - channels: { - bluebubbles: { - contextVisibility: "allowlist_quote", - }, - }, - } as OpenClawConfig, - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "replying now", - isGroup: true, - chatGuid: "iMessage;+;chat-reply-visibility", - replyTo: { - guid: "msg-0", - text: "quoted context", - handle: { address: "+15550000000", displayName: "Alice" }, - }, - }); - - await dispatchWebhookPayload(payload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); - const callArgs = getFirstDispatchCall(); - expect(callArgs.ctx.ReplyToId).toBe("msg-0"); - expect(callArgs.ctx.ReplyToBody).toBe("quoted context"); - expect(callArgs.ctx.ReplyToSender).toBe("+15550000000"); - expect(callArgs.ctx.Body).toContain("[[reply_to:msg-0]]"); - }); - - it("preserves part index prefixes in reply tags when short IDs are unavailable", async () => { - setupWebhookTarget(); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "replying now", - chatGuid: "iMessage;-;+15551234567", - replyTo: { - guid: "p:1/msg-0", - text: "original message", - handle: { address: "+15550000000", displayName: "Alice" }, - }, - }); - - await dispatchWebhookPayload(payload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); - const callArgs = getFirstDispatchCall(); - expect(callArgs.ctx.ReplyToId).toBe("p:1/msg-0"); - expect(callArgs.ctx.ReplyToIdFull).toBe("p:1/msg-0"); - expect(callArgs.ctx.Body).toContain("[[reply_to:p:1/msg-0]]"); - }); - - it("hydrates missing reply sender/body from the recent-message cache", async () => { - setupWebhookTarget(); - - const chatGuid = "iMessage;+;chat-reply-cache"; - - const originalPayload = createTimestampedNewMessagePayloadForTest({ - text: "original message (cached)", - handle: { address: "+15550000000" }, - isGroup: true, - guid: "cache-msg-0", - chatGuid, - }); - - await dispatchWebhookPayload(originalPayload); - - // Only assert the reply message behavior below. - mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); - - const replyPayload = createTimestampedNewMessagePayloadForTest({ - text: "replying now", - isGroup: true, - guid: "cache-msg-1", - chatGuid, - // Only the GUID is provided; sender/body must be hydrated. - replyToMessageGuid: "cache-msg-0", - }); - - await dispatchWebhookPayload(replyPayload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); - const callArgs = getFirstDispatchCall(); - // ReplyToId uses short ID "1" (first cached message) for token savings - expect(callArgs.ctx.ReplyToId).toBe("1"); - expect(callArgs.ctx.ReplyToIdFull).toBe("cache-msg-0"); - expect(callArgs.ctx.ReplyToBody).toBe("original message (cached)"); - expect(callArgs.ctx.ReplyToSender).toBe("+15550000000"); - // 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 () => { - setupWebhookTarget(); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "replying now", - threadOriginatorGuid: "msg-0", - chatGuid: "iMessage;-;+15551234567", - }); - - await dispatchWebhookPayload(payload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); - const callArgs = getFirstDispatchCall(); - expect(callArgs.ctx.ReplyToId).toBe("msg-0"); - }); - }); - - describe("tapback text parsing", () => { - it("does not rewrite tapback-like text without metadata", async () => { - setupWebhookTarget(); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "Loved this idea", - chatGuid: "iMessage;-;+15551234567", - }); - - await dispatchWebhookPayload(payload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); - const callArgs = getFirstDispatchCall(); - 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 () => { - setupWebhookTarget(); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: 'Reacted 😅 to "nice one"', - guid: "msg-2", - chatGuid: "iMessage;-;+15551234567", - }); - - await dispatchWebhookPayload(payload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); - const callArgs = getFirstDispatchCall(); - 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"); - vi.mocked(sendBlueBubblesReaction).mockClear(); - - setupWebhookTarget({ - config: { - messages: { - ackReaction: "❤️", - ackReactionScope: "direct", - }, - }, - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - chatGuid: "iMessage;-;+15551234567", - }); - - await dispatchWebhookPayload(payload); - - expect(sendBlueBubblesReaction).toHaveBeenCalledWith( - expect.objectContaining({ - chatGuid: "iMessage;-;+15551234567", - messageGuid: "msg-1", - emoji: "❤️", - opts: expect.objectContaining({ accountId: "default" }), - }), - ); - }); - }); - - describe("command gating", () => { - it("allows control command to bypass mention gating when authorized", async () => { - mockResolveRequireMention.mockReturnValue(true); - mockMatchesMentionPatterns.mockReturnValue(false); // Not mentioned - mockHasControlCommand.mockReturnValue(true); // Has control command - mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(true); // Authorized - - setupWebhookTarget({ - account: createMockAccount({ - groupPolicy: "open", - allowFrom: ["+15551234567"], - }), - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "/status", - isGroup: true, - chatGuid: "iMessage;+;chat123456", - }); - - await dispatchWebhookPayload(payload); - - // Should process even without mention because it's an authorized control command - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); - }); - - it("blocks control command from unauthorized sender in group", async () => { - mockHasControlCommand.mockReturnValue(true); - mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(false); - - setupWebhookTarget({ - account: createMockAccount({ - groupPolicy: "open", - allowFrom: [], // No one authorized - }), - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "/status", - handle: { address: "+15559999999" }, - isGroup: true, - chatGuid: "iMessage;+;chat123456", - }); - - await dispatchWebhookPayload(payload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("drops DM control commands in open mode without allowlists", async () => { - mockHasControlCommand.mockReturnValue(true); - - setupWebhookTarget({ - account: createMockAccount({ - dmPolicy: "open", - allowFrom: [], - }), - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "/status", - handle: { address: "+15559999999" }, - guid: "msg-dm-open-unauthorized", - }); - - await dispatchWebhookPayload(payload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - }); - - describe("typing/read receipt toggles", () => { - it("marks chat as read when sendReadReceipts=true (default)", async () => { - const { markBlueBubblesChatRead } = await import("./chat.js"); - vi.mocked(markBlueBubblesChatRead).mockClear(); - - setupWebhookTarget({ - account: createMockAccount({ - sendReadReceipts: true, - }), - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - chatGuid: "iMessage;-;+15551234567", - }); - - await dispatchWebhookPayload(payload); - - expect(markBlueBubblesChatRead).toHaveBeenCalled(); - }); - - it("does not mark chat as read when sendReadReceipts=false", async () => { - const { markBlueBubblesChatRead } = await import("./chat.js"); - vi.mocked(markBlueBubblesChatRead).mockClear(); - - setupWebhookTarget({ - account: createMockAccount({ - sendReadReceipts: false, - }), - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - chatGuid: "iMessage;-;+15551234567", - }); - - await dispatchWebhookPayload(payload); - - expect(markBlueBubblesChatRead).not.toHaveBeenCalled(); - }); - - it("sends typing indicator when processing message", async () => { - const { sendBlueBubblesTyping } = await import("./chat.js"); - vi.mocked(sendBlueBubblesTyping).mockClear(); - - setupWebhookTarget(); - - const payload = createTimestampedNewMessagePayloadForTest({ - chatGuid: "iMessage;-;+15551234567", - }); - - mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { - await params.dispatcherOptions.onReplyStart?.(); - return EMPTY_DISPATCH_RESULT; - }); - - await dispatchWebhookPayload(payload); - - // Should call typing start when reply flow triggers it. - expect(sendBlueBubblesTyping).toHaveBeenCalledWith( - expect.any(String), - true, - expect.any(Object), - ); - }); - - it("stops typing on idle", async () => { - const { sendBlueBubblesTyping } = await import("./chat.js"); - vi.mocked(sendBlueBubblesTyping).mockClear(); - - setupWebhookTarget(); - - const payload = createTimestampedNewMessagePayloadForTest({ - chatGuid: "iMessage;-;+15551234567", - }); - - mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { - await params.dispatcherOptions.onReplyStart?.(); - await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" }); - params.dispatcherOptions.onIdle?.(); - return EMPTY_DISPATCH_RESULT; - }); - - await dispatchWebhookPayload(payload); - - expect(sendBlueBubblesTyping).toHaveBeenCalledWith( - expect.any(String), - false, - expect.any(Object), - ); - }); - - it("stops typing when no reply is sent", async () => { - const { sendBlueBubblesTyping } = await import("./chat.js"); - vi.mocked(sendBlueBubblesTyping).mockClear(); - - setupWebhookTarget(); - - const payload = createTimestampedNewMessagePayloadForTest({ - chatGuid: "iMessage;-;+15551234567", - }); - - mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( - async () => EMPTY_DISPATCH_RESULT, - ); - - await dispatchWebhookPayload(payload); - - expect(sendBlueBubblesTyping).toHaveBeenCalledWith( - expect.any(String), - false, - expect.any(Object), - ); - }); - }); - - describe("outbound message ids", () => { - it("enqueues system event for outbound message id", async () => { - mockEnqueueSystemEvent.mockClear(); - - mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { - await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" }); - return EMPTY_DISPATCH_RESULT; - }); - - setupWebhookTarget(); - - const payload = createTimestampedNewMessagePayloadForTest({ - chatGuid: "iMessage;-;+15551234567", - }); - - await dispatchWebhookPayload(payload); - - // Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2") - expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( - 'Assistant sent "replying now" [message_id:2]', - expect.objectContaining({ - sessionKey: "agent:main:main", - }), - ); - }); - - it("falls back to from-me webhook when send response has no message id", async () => { - mockEnqueueSystemEvent.mockClear(); - - const { sendMessageBlueBubbles } = await import("./send.js"); - vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce(blueBubblesTestSendResult("ok")); - - mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { - await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" }); - return EMPTY_DISPATCH_RESULT; - }); - - setupWebhookTarget(); - - const inboundPayload = createTimestampedNewMessagePayloadForTest({ - chatGuid: "iMessage;-;+15551234567", - }); - - await dispatchWebhookPayload(inboundPayload); - - // Send response did not include a message id, so nothing should be enqueued yet. - expect(mockEnqueueSystemEvent).not.toHaveBeenCalled(); - - const fromMePayload = createTimestampedNewMessagePayloadForTest({ - text: "replying now", - handle: { address: "+15557654321" }, - isFromMe: true, - guid: "msg-out-456", - chatGuid: "iMessage;-;+15551234567", - }); - - await dispatchWebhookPayload(fromMePayload); - - expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( - 'Assistant sent "replying now" [message_id:2]', - expect.objectContaining({ - sessionKey: "agent:main:main", - }), - ); - }); - - it("matches from-me fallback by chatIdentifier when chatGuid is missing", async () => { - mockEnqueueSystemEvent.mockClear(); - - const { sendMessageBlueBubbles } = await import("./send.js"); - vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce(blueBubblesTestSendResult("ok")); - - mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { - await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" }); - return EMPTY_DISPATCH_RESULT; - }); - - setupWebhookTarget(); - - const inboundPayload = createTimestampedNewMessagePayloadForTest({ - chatGuid: "iMessage;-;+15551234567", - }); - - await dispatchWebhookPayload(inboundPayload); - - expect(mockEnqueueSystemEvent).not.toHaveBeenCalled(); - - const fromMePayload = createTimestampedNewMessagePayloadForTest({ - text: "replying now", - handle: { address: "+15557654321" }, - isFromMe: true, - guid: "msg-out-789", - chatIdentifier: "+15551234567", - }); - - await dispatchWebhookPayload(fromMePayload); - - expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( - 'Assistant sent "replying now" [message_id:2]', - expect.objectContaining({ - sessionKey: "agent:main:main", - }), - ); - }); - }); - - describe("reaction events", () => { - it("drops DM reactions when dmPolicy=pairing and allowFrom is empty", async () => { - mockEnqueueSystemEvent.mockClear(); - - setupWebhookTarget({ - account: createMockAccount({ dmPolicy: "pairing", allowFrom: [] }), - }); - - const payload = createTimestampedMessageReactionPayloadForTest(); - - await dispatchWebhookPayload(payload); - - expect(mockEnqueueSystemEvent).not.toHaveBeenCalled(); - }); - - it("skips group reactions when requireMention=true", async () => { - mockEnqueueSystemEvent.mockClear(); - mockResolveRequireMention.mockReturnValue(true); - - setupWebhookTarget({ - account: createMockAccount({ - groupPolicy: "open", - }), - }); - - const payload = createTimestampedMessageReactionPayloadForTest({ - isGroup: true, - chatGuid: "iMessage;+;chat123456", - associatedMessageType: 2000, - handle: { address: "+15559999999" }, - }); - - await dispatchWebhookPayload(payload); - - expect(mockEnqueueSystemEvent).not.toHaveBeenCalled(); - }); - - it("enqueues system event for reaction added", async () => { - mockEnqueueSystemEvent.mockClear(); - - setupWebhookTarget(); - - const payload = createTimestampedMessageReactionPayloadForTest({ - associatedMessageType: 2000, // Heart reaction added - }); - - await dispatchWebhookPayload(payload); - - expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( - expect.stringContaining("reacted with ❤️ [[reply_to:"), - expect.any(Object), - ); - }); - - it("enqueues group reactions when requireMention=false", async () => { - mockEnqueueSystemEvent.mockClear(); - mockResolveRequireMention.mockReturnValue(false); - - setupWebhookTarget({ - account: createMockAccount({ - groupPolicy: "open", - }), - }); - - const payload = createTimestampedMessageReactionPayloadForTest({ - isGroup: true, - chatGuid: "iMessage;+;chat123456", - associatedMessageType: 2000, - handle: { address: "+15559999999" }, - }); - - await dispatchWebhookPayload(payload); - - expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( - expect.stringContaining("reacted with ❤️ [[reply_to:"), - expect.any(Object), - ); - }); - - it("enqueues system event for reaction removed", async () => { - mockEnqueueSystemEvent.mockClear(); - - setupWebhookTarget(); - - const payload = createTimestampedMessageReactionPayloadForTest({ - associatedMessageType: 3000, // Heart reaction removed - }); - - await dispatchWebhookPayload(payload); - - expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( - expect.stringContaining("removed ❤️ reaction [[reply_to:"), - expect.any(Object), - ); - }); - - it("ignores reaction from self (fromMe=true)", async () => { - mockEnqueueSystemEvent.mockClear(); - - setupWebhookTarget(); - - const payload = createTimestampedMessageReactionPayloadForTest({ - isFromMe: true, // From self - }); - - await dispatchWebhookPayload(payload); - - expect(mockEnqueueSystemEvent).not.toHaveBeenCalled(); - }); - - it("drops group reactions that arrive with no chat identifiers", async () => { - // Real-world failure mode: BlueBubbles fires a reaction webhook with - // isGroup=true but omits chatGuid AND chatId AND chatIdentifier. The - // legacy code falls peerId back to the literal string "group" and - // resolves a session key unrelated to any real binding; if isGroup - // had been misclassified as false the same payload would have been - // routed to the sender's DM session instead — surfacing a group - // tapback inside an unrelated 1:1 transcript. Either way the event - // cannot be routed correctly, so drop it. - mockEnqueueSystemEvent.mockClear(); - mockResolveRequireMention.mockReturnValue(false); - - setupWebhookTarget({ - account: createMockAccount({ groupPolicy: "open" }), - }); - - const payload = createTimestampedMessageReactionPayloadForTest({ - isGroup: true, - // chatGuid / chatId / chatIdentifier intentionally omitted - associatedMessageType: 2000, - handle: { address: "+15559999999" }, - }); - - await dispatchWebhookPayload(payload); - - expect(mockEnqueueSystemEvent).not.toHaveBeenCalled(); - }); - - it("still enqueues group reactions when at least one chat identifier is present", async () => { - // Sanity check: the drop guard must not fire when the webhook does - // include a chatGuid. - mockEnqueueSystemEvent.mockClear(); - mockResolveRequireMention.mockReturnValue(false); - - setupWebhookTarget({ - account: createMockAccount({ groupPolicy: "open" }), - }); - - const payload = createTimestampedMessageReactionPayloadForTest({ - isGroup: true, - chatGuid: "iMessage;+;chat-known-123", - associatedMessageType: 2000, - handle: { address: "+15559999999" }, - }); - - await dispatchWebhookPayload(payload); - - expect(mockEnqueueSystemEvent).toHaveBeenCalled(); - }); - - it("maps reaction types to correct emojis", async () => { - mockEnqueueSystemEvent.mockClear(); - - setupWebhookTarget(); - - // Test thumbs up reaction (2001) - const payload = createTimestampedMessageReactionPayloadForTest({ - associatedMessageGuid: "msg-123", - associatedMessageType: 2001, // Thumbs up - }); - - await dispatchWebhookPayload(payload); - - expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( - expect.stringContaining("👍"), - expect.any(Object), - ); - }); - }); - - describe("short message ID mapping", () => { - it("assigns sequential short IDs to messages", async () => { - setupWebhookTarget(); - - const payload = createTimestampedNewMessagePayloadForTest({ - guid: "p:1/msg-uuid-12345", - chatGuid: "iMessage;-;+15551234567", - }); - - await dispatchWebhookPayload(payload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); - const callArgs = getFirstDispatchCall(); - // MessageSid should be short ID "1" instead of full UUID - expect(callArgs.ctx.MessageSid).toBe("1"); - expect(callArgs.ctx.MessageSidFull).toBe("p:1/msg-uuid-12345"); - }); - - it("resolves short ID back to UUID", async () => { - setupWebhookTarget(); - - const payload = createTimestampedNewMessagePayloadForTest({ - guid: "p:1/msg-uuid-12345", - chatGuid: "iMessage;-;+15551234567", - }); - - await dispatchWebhookPayload(payload); - - // The short ID "1" should resolve back to the full UUID - expect(resolveBlueBubblesMessageId("1")).toBe("p:1/msg-uuid-12345"); - }); - - it("returns UUID unchanged when not in cache", () => { - expect(resolveBlueBubblesMessageId("msg-not-cached")).toBe("msg-not-cached"); - }); - - 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("history backfill", () => { - it("scopes in-memory history by account to avoid cross-account leakage", async () => { - mockFetchBlueBubblesHistory.mockImplementation(async (_chatIdentifier, _limit, opts) => { - if (opts?.accountId === "acc-a") { - return { - resolved: true, - entries: [ - { sender: "A", body: "a-history", messageId: "a-history-1", timestamp: 1000 }, - ], - }; - } - if (opts?.accountId === "acc-b") { - return { - resolved: true, - entries: [ - { sender: "B", body: "b-history", messageId: "b-history-1", timestamp: 1000 }, - ], - }; - } - return { resolved: true, entries: [] }; - }); - - const accountA: ResolvedBlueBubblesAccount = { - ...createMockAccount({ dmHistoryLimit: 3, password: "password-a" }), // pragma: allowlist secret - accountId: "acc-a", - }; - const accountB: ResolvedBlueBubblesAccount = { - ...createMockAccount({ dmHistoryLimit: 3, password: "password-b" }), // pragma: allowlist secret - accountId: "acc-b", - }; - const core = createMockRuntime(); - trackWebhookRegistrationForTest( - setupWebhookTargetsForTest({ - createCore: createMockRuntime, - core, - accounts: [{ account: accountA }, { account: accountB }], - }), - (nextUnregister) => { - unregister = nextUnregister; - }, - ); - - await dispatchWebhookPayload( - createTimestampedNewMessagePayloadForTest({ - text: "message for account a", - guid: "a-msg-1", - chatGuid: "iMessage;-;+15551234567", - }), - "/bluebubbles-webhook?password=password-a", - ); - - await dispatchWebhookPayload( - createTimestampedNewMessagePayloadForTest({ - text: "message for account b", - guid: "b-msg-1", - chatGuid: "iMessage;-;+15551234567", - }), - "/bluebubbles-webhook?password=password-b", - ); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(2); - const firstCall = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0]; - const secondCall = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[1]?.[0]; - const firstHistory = (firstCall?.ctx.InboundHistory ?? []) as Array<{ body: string }>; - const secondHistory = (secondCall?.ctx.InboundHistory ?? []) as Array<{ body: string }>; - expect(firstHistory.map((entry) => entry.body)).toContain("a-history"); - expect(secondHistory.map((entry) => entry.body)).toContain("b-history"); - expect(secondHistory.map((entry) => entry.body)).not.toContain("a-history"); - }); - - it("dedupes and caps merged history to dmHistoryLimit", async () => { - mockFetchBlueBubblesHistory.mockResolvedValueOnce({ - resolved: true, - entries: [ - { sender: "Friend", body: "older context", messageId: "hist-1", timestamp: 1000 }, - { sender: "Friend", body: "current text", messageId: "msg-1", timestamp: 2000 }, - ], - }); - - setupWebhookTarget({ - account: createMockAccount({ dmHistoryLimit: 2 }), - }); - - await dispatchWebhookPayload( - createTimestampedNewMessagePayloadForTest({ - text: "current text", - chatGuid: "iMessage;-;+15550002002", - }), - ); - - const callArgs = getFirstDispatchCall(); - const inboundHistory = (callArgs.ctx.InboundHistory ?? []) as Array<{ body: string }>; - expect(inboundHistory).toHaveLength(2); - expect(inboundHistory.map((entry) => entry.body)).toEqual(["older context", "current text"]); - expect(inboundHistory.filter((entry) => entry.body === "current text")).toHaveLength(1); - }); - - it("uses exponential backoff for unresolved backfill and stops after resolve", async () => { - mockFetchBlueBubblesHistory - .mockResolvedValueOnce({ resolved: false, entries: [] }) - .mockResolvedValueOnce({ - resolved: true, - entries: [ - { sender: "Friend", body: "older context", messageId: "hist-1", timestamp: 1000 }, - ], - }); - - setupWebhookTarget({ - account: createMockAccount({ dmHistoryLimit: 4 }), - }); - - const mkPayload = (guid: string, text: string, now: number) => - createNewMessagePayloadForTest({ - text, - guid, - chatGuid: "iMessage;-;+15550003003", - date: now, - }); - - let now = 1_700_000_000_000; - const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); - try { - await dispatchWebhookPayload(mkPayload("msg-1", "first text", now)); - expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(1); - - now += 1_000; - await dispatchWebhookPayload(mkPayload("msg-2", "second text", now)); - expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(1); - - now += 6_000; - await dispatchWebhookPayload(mkPayload("msg-3", "third text", now)); - expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(2); - - const thirdCall = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[2]?.[0]; - const thirdHistory = (thirdCall?.ctx.InboundHistory ?? []) as Array<{ body: string }>; - expect(thirdHistory.map((entry) => entry.body)).toContain("older context"); - expect(thirdHistory.map((entry) => entry.body)).toContain("third text"); - - now += 10_000; - await dispatchWebhookPayload(mkPayload("msg-4", "fourth text", now)); - expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(2); - } finally { - nowSpy.mockRestore(); - } - }); - - it("caps inbound history payload size to reduce prompt-bomb risk", async () => { - const huge = "x".repeat(8_000); - mockFetchBlueBubblesHistory.mockResolvedValueOnce({ - resolved: true, - entries: Array.from({ length: 20 }, (_, idx) => ({ - sender: `Friend ${idx}`, - body: `${huge} ${idx}`, - messageId: `hist-${idx}`, - timestamp: idx + 1, - })), - }); - - setupWebhookTarget({ - account: createMockAccount({ dmHistoryLimit: 20 }), - }); - - await dispatchWebhookPayload( - createTimestampedNewMessagePayloadForTest({ - text: "latest text", - guid: "msg-bomb-1", - chatGuid: "iMessage;-;+15550004004", - }), - ); - - const callArgs = getFirstDispatchCall(); - const inboundHistory = (callArgs.ctx.InboundHistory ?? []) as Array<{ body: string }>; - const totalChars = inboundHistory.reduce((sum, entry) => sum + entry.body.length, 0); - expect(inboundHistory.length).toBeLessThan(20); - expect(totalChars).toBeLessThanOrEqual(12_000); - expect(inboundHistory.every((entry) => entry.body.length <= 1_203)).toBe(true); - }); - }); - - describe("fromMe messages", () => { - it("ignores messages from self (fromMe=true)", async () => { - setupWebhookTarget(); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "my own message", - isFromMe: true, - }); - - await dispatchWebhookPayload(payload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("drops reflected self-chat duplicates after a confirmed assistant outbound", async () => { - setupWebhookTarget(); - - const { sendMessageBlueBubbles } = await import("./send.js"); - vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce( - blueBubblesTestSendResult("msg-self-1"), - ); - - mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { - await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" }); - return EMPTY_DISPATCH_RESULT; - }); - - const timestamp = Date.now(); - const inboundPayload = createNewMessagePayloadForTest({ - guid: "msg-self-0", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }); - - await dispatchWebhookPayload(inboundPayload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); - mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); - - const fromMePayload = createNewMessagePayloadForTest({ - text: "replying now", - isFromMe: true, - guid: "msg-self-1", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }); - - await dispatchWebhookPayload(fromMePayload); - - const reflectedPayload = createNewMessagePayloadForTest({ - text: "replying now", - guid: "msg-self-2", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }); - - await dispatchWebhookPayload(reflectedPayload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("does not drop inbound messages when no fromMe self-chat copy was seen", async () => { - setupWebhookTarget(); - - const inboundPayload = createTimestampedNewMessagePayloadForTest({ - text: "genuinely new message", - guid: "msg-inbound-1", - chatGuid: "iMessage;-;+15551234567", - }); - - await dispatchWebhookPayload(inboundPayload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); - }); - - it("does not drop reflected copies after the self-chat cache TTL expires", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-07T00:00:00Z")); - - setupWebhookTarget(); - - const timestamp = Date.now(); - const fromMePayload = createNewMessagePayloadForTest({ - text: "ttl me", - isFromMe: true, - guid: "msg-self-ttl-1", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }); - - await dispatchWebhookPayloadDirect(fromMePayload); - await vi.runAllTimersAsync(); - - mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); - vi.advanceTimersByTime(10_001); - - const reflectedPayload = createNewMessagePayloadForTest({ - text: "ttl me", - guid: "msg-self-ttl-2", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }); - - await dispatchWebhookPayloadDirect(reflectedPayload); - await vi.runAllTimersAsync(); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); - }); - - it("does not cache regular fromMe DMs as self-chat reflections", async () => { - setupWebhookTarget(); - - const timestamp = Date.now(); - const fromMePayload = createNewMessagePayloadForTest({ - text: "shared text", - handle: { address: "+15557654321" }, - isFromMe: true, - guid: "msg-normal-fromme", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }); - - await dispatchWebhookPayload(fromMePayload); - - mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); - - const inboundPayload = createNewMessagePayloadForTest({ - text: "shared text", - guid: "msg-normal-inbound", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }); - - await dispatchWebhookPayload(inboundPayload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); - }); - - it("does not drop user-authored self-chat prompts without a confirmed assistant outbound", async () => { - setupWebhookTarget(); - - const timestamp = Date.now(); - const fromMePayload = createNewMessagePayloadForTest({ - text: "user-authored self prompt", - isFromMe: true, - guid: "msg-self-user-1", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }); - - await dispatchWebhookPayload(fromMePayload); - - mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); - - const reflectedPayload = createNewMessagePayloadForTest({ - text: "user-authored self prompt", - guid: "msg-self-user-2", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }); - - await dispatchWebhookPayload(reflectedPayload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); - }); - - it("does not treat a pending text-only match as confirmed assistant outbound", async () => { - setupWebhookTarget(); - - const { sendMessageBlueBubbles } = await import("./send.js"); - vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce(blueBubblesTestSendResult("ok")); - - mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { - await params.dispatcherOptions.deliver({ text: "same text" }, { kind: "final" }); - return EMPTY_DISPATCH_RESULT; - }); - - const timestamp = Date.now(); - const inboundPayload = createNewMessagePayloadForTest({ - guid: "msg-self-race-0", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }); - - await dispatchWebhookPayload(inboundPayload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); - mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); - - const fromMePayload = createNewMessagePayloadForTest({ - text: "same text", - isFromMe: true, - guid: "msg-self-race-1", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }); - - await dispatchWebhookPayload(fromMePayload); - - const reflectedPayload = createNewMessagePayloadForTest({ - text: "same text", - guid: "msg-self-race-2", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }); - - await dispatchWebhookPayload(reflectedPayload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); - }); - - it("does not treat chatGuid-inferred sender ids as self-chat evidence", async () => { - setupWebhookTarget(); - - const timestamp = Date.now(); - const fromMePayload = createNewMessagePayloadForTest({ - text: "shared inferred text", - handle: null, - isFromMe: true, - guid: "msg-inferred-fromme", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }); - - await dispatchWebhookPayload(fromMePayload); - - mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); - - const inboundPayload = createNewMessagePayloadForTest({ - text: "shared inferred text", - guid: "msg-inferred-inbound", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }); - - await dispatchWebhookPayload(inboundPayload); - - expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); - }); - }); -}); diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts deleted file mode 100644 index b86fe08b4c76..000000000000 --- a/extensions/bluebubbles/src/monitor.ts +++ /dev/null @@ -1,398 +0,0 @@ -import type { IncomingMessage, ServerResponse } from "node:http"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { resolveBlueBubblesEffectiveAllowPrivateNetwork } from "./accounts.js"; -import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js"; -import { - asRecord, - normalizeWebhookMessage, - normalizeWebhookReaction, -} from "./monitor-normalize.js"; -import { logVerbose, processMessage, processReaction } from "./monitor-processing.js"; -import { - _resetBlueBubblesShortIdState, - resolveBlueBubblesMessageId, -} from "./monitor-reply-cache.js"; -import { - DEFAULT_WEBHOOK_PATH, - normalizeWebhookPath, - resolveWebhookPathFromConfig, - type BlueBubblesMonitorOptions, - type WebhookTarget, -} from "./monitor-shared.js"; -import { fetchBlueBubblesServerInfo } from "./probe.js"; -import { getBlueBubblesRuntime } from "./runtime.js"; -import { normalizeSecretInputString } from "./secret-input.js"; -import { - WEBHOOK_RATE_LIMIT_DEFAULTS, - createFixedWindowRateLimiter, - createWebhookInFlightLimiter, - registerWebhookTargetWithPluginRoute, - readWebhookBodyOrReject, - resolveRequestClientIp, - resolveWebhookTargetWithAuthOrRejectSync, - withResolvedWebhookRequestPipeline, -} from "./webhook-ingress.js"; - -const webhookTargets = new Map(); -const webhookRateLimiter = createFixedWindowRateLimiter({ - windowMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs, - maxRequests: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests, - maxTrackedKeys: WEBHOOK_RATE_LIMIT_DEFAULTS.maxTrackedKeys, -}); -const webhookInFlightLimiter = createWebhookInFlightLimiter(); -const debounceRegistry = createBlueBubblesDebounceRegistry({ processMessage }); - -export function clearBlueBubblesWebhookSecurityStateForTest(): void { - webhookRateLimiter.clear(); - webhookInFlightLimiter.clear(); -} - -export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void { - const registered = registerWebhookTargetWithPluginRoute({ - targetsByPath: webhookTargets, - target, - route: { - auth: "plugin", - match: "exact", - pluginId: "bluebubbles", - source: "bluebubbles-webhook", - accountId: target.account.accountId, - log: target.runtime.log, - handler: async (req, res) => { - const handled = await handleBlueBubblesWebhookRequest(req, res); - if (!handled && !res.headersSent) { - res.statusCode = 404; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Not Found"); - } - }, - }, - }); - return () => { - registered.unregister(); - // Clean up debouncer when target is unregistered - debounceRegistry.removeDebouncer(registered.target); - }; -} - -function parseBlueBubblesWebhookPayload( - rawBody: string, -): { ok: true; value: unknown } | { ok: false; error: string } { - const trimmed = rawBody.trim(); - if (!trimmed) { - return { ok: false, error: "empty payload" }; - } - try { - return { ok: true, value: JSON.parse(trimmed) as unknown }; - } catch { - const params = new URLSearchParams(rawBody); - const payload = params.get("payload") ?? params.get("data") ?? params.get("message"); - if (!payload) { - return { ok: false, error: "invalid json" }; - } - try { - return { ok: true, value: JSON.parse(payload) as unknown }; - } catch (error) { - return { ok: false, error: formatErrorMessage(error) }; - } - } -} - -function maskSecret(value: string): string { - if (value.length <= 6) { - return "***"; - } - return `${value.slice(0, 2)}***${value.slice(-2)}`; -} - -function normalizeAuthToken(raw: string): string { - const value = raw.trim(); - if (!value) { - return ""; - } - if (normalizeLowercaseStringOrEmpty(value).startsWith("bearer ")) { - return value.slice("bearer ".length).trim(); - } - return value; -} - -function safeEqualAuthToken(aRaw: string, bRaw: string): boolean { - const a = normalizeAuthToken(aRaw); - const b = normalizeAuthToken(bRaw); - if (!a || !b) { - return false; - } - return safeEqualSecret(a, b); -} - -function collectTrustedProxies(targets: readonly WebhookTarget[]): string[] { - const proxies = new Set(); - for (const target of targets) { - for (const proxy of target.config.gateway?.trustedProxies ?? []) { - const normalized = proxy.trim(); - if (normalized) { - proxies.add(normalized); - } - } - } - return [...proxies]; -} - -function resolveWebhookAllowRealIpFallback(targets: readonly WebhookTarget[]): boolean { - return targets.some((target) => target.config.gateway?.allowRealIpFallback === true); -} - -function resolveWebhookClientIp( - req: IncomingMessage, - trustedProxies: readonly string[], - allowRealIpFallback: boolean, -): string { - if (!req.headers["x-forwarded-for"] && !(allowRealIpFallback && req.headers["x-real-ip"])) { - return req.socket.remoteAddress ?? "unknown"; - } - - // Mirror gateway client-IP trust rules so limiter buckets follow configured proxy hops. - return ( - resolveRequestClientIp(req, [...trustedProxies], allowRealIpFallback) ?? - req.socket.remoteAddress ?? - "unknown" - ); -} - -export async function handleBlueBubblesWebhookRequest( - req: IncomingMessage, - res: ServerResponse, -): Promise { - const requestUrl = new URL(req.url ?? "/", "http://localhost"); - const normalizedPath = normalizeWebhookPath(requestUrl.pathname); - const pathTargets = webhookTargets.get(normalizedPath) ?? []; - const trustedProxies = collectTrustedProxies(pathTargets); - const allowRealIpFallback = resolveWebhookAllowRealIpFallback(pathTargets); - const clientIp = resolveWebhookClientIp(req, trustedProxies, allowRealIpFallback); - const rateLimitKey = `${normalizedPath}:${clientIp}`; - return await withResolvedWebhookRequestPipeline({ - req, - res, - targetsByPath: webhookTargets, - allowMethods: ["POST"], - rateLimiter: webhookRateLimiter, - rateLimitKey, - inFlightLimiter: webhookInFlightLimiter, - inFlightKey: `${normalizedPath}:${clientIp}`, - handle: async ({ path, targets }) => { - const url = requestUrl; - const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password"); - const headerToken = - req.headers["x-guid"] ?? - req.headers["x-password"] ?? - req.headers["x-bluebubbles-guid"] ?? - req.headers["authorization"]; - const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? ""; - const target = resolveWebhookTargetWithAuthOrRejectSync({ - targets, - res, - isMatch: (target) => { - const token = normalizeSecretInputString(target.account.config.password) ?? ""; - return safeEqualAuthToken(guid, token); - }, - }); - if (!target) { - console.warn( - `[bluebubbles] webhook rejected: status=${res.statusCode} path=${path} guid=${maskSecret(url.searchParams.get("guid") ?? url.searchParams.get("password") ?? "")}`, - ); - return true; - } - const body = await readWebhookBodyOrReject({ - req, - res, - profile: "post-auth", - invalidBodyMessage: "invalid payload", - }); - if (!body.ok) { - console.warn(`[bluebubbles] webhook rejected: status=${res.statusCode}`); - return true; - } - - const parsed = parseBlueBubblesWebhookPayload(body.value); - if (!parsed.ok) { - res.statusCode = 400; - res.end(parsed.error); - console.warn(`[bluebubbles] webhook rejected: ${parsed.error}`); - return true; - } - - const payload = asRecord(parsed.value) ?? {}; - const firstTarget = targets[0]; - if (firstTarget) { - logVerbose( - firstTarget.core, - firstTarget.runtime, - `webhook received path=${path} keys=${Object.keys(payload).join(",") || "none"}`, - ); - } - const eventTypeRaw = payload.type; - const eventType = typeof eventTypeRaw === "string" ? eventTypeRaw.trim() : ""; - const allowedEventTypes = new Set([ - "new-message", - "updated-message", - "message-reaction", - "reaction", - ]); - if (eventType && !allowedEventTypes.has(eventType)) { - res.statusCode = 200; - res.end("ok"); - if (firstTarget) { - logVerbose(firstTarget.core, firstTarget.runtime, `webhook ignored type=${eventType}`); - } - return true; - } - const reaction = normalizeWebhookReaction(payload); - // Normalize the webhook message early so the attachment-update detection - // below sees attachments under any supported wrapper format (`payload.data`, - // `payload.message`, `payload.data.message`, JSON-string payloads), not just - // raw `payload.data.attachments`. (#65430, #67510) - const message = reaction ? null : normalizeWebhookMessage(payload, { eventType }); - // BlueBubbles fires `updated-message` when attachments are indexed after the - // initial `new-message` (which may arrive with attachments: []). Let those - // through so the agent can ingest the image. (#65430) - const isAttachmentUpdate = - eventType === "updated-message" && (message?.attachments?.length ?? 0) > 0; - if ( - (eventType === "updated-message" || - eventType === "message-reaction" || - eventType === "reaction") && - !reaction && - !isAttachmentUpdate - ) { - res.statusCode = 200; - res.end("ok"); - if (firstTarget) { - logVerbose( - firstTarget.core, - firstTarget.runtime, - `webhook ignored ${eventType || "event"} (no reaction or attachment update)`, - ); - } - return true; - } - if (!message && !reaction) { - res.statusCode = 400; - res.end("invalid payload"); - console.warn("[bluebubbles] webhook rejected: unable to parse message payload"); - return true; - } - - target.statusSink?.({ lastInboundAt: Date.now() }); - if (reaction) { - processReaction(reaction, target).catch((err) => { - target.runtime.error?.( - `[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`, - ); - }); - } else if (message) { - // Route messages through debouncer to coalesce rapid-fire events - // (e.g., text message + URL balloon arriving as separate webhooks) - const debouncer = debounceRegistry.getOrCreateDebouncer(target); - debouncer.enqueue({ message, target }).catch((err) => { - target.runtime.error?.( - `[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`, - ); - }); - } - - res.statusCode = 200; - res.end("ok"); - if (reaction) { - if (firstTarget) { - logVerbose( - firstTarget.core, - firstTarget.runtime, - `webhook accepted reaction sender=${reaction.senderId} msg=${reaction.messageId} action=${reaction.action}`, - ); - } - } else if (message) { - if (firstTarget) { - logVerbose( - firstTarget.core, - firstTarget.runtime, - `webhook accepted sender=${message.senderId} group=${message.isGroup} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`, - ); - } - } - return true; - }, - }); -} - -export async function monitorBlueBubblesProvider( - options: BlueBubblesMonitorOptions, -): Promise { - const { account, config, runtime, abortSignal, statusSink } = options; - const core = getBlueBubblesRuntime(); - const path = options.webhookPath?.trim() || DEFAULT_WEBHOOK_PATH; - const allowPrivateNetwork = resolveBlueBubblesEffectiveAllowPrivateNetwork({ - baseUrl: account.baseUrl, - config: account.config, - }); - - // Fetch and cache server info (for macOS version detection in action gating) - const serverInfo = await fetchBlueBubblesServerInfo({ - baseUrl: account.baseUrl, - password: account.config.password, - accountId: account.accountId, - timeoutMs: 5000, - allowPrivateNetwork, - }).catch(() => null); - if (serverInfo?.os_version) { - runtime.log?.(`[${account.accountId}] BlueBubbles server macOS ${serverInfo.os_version}`); - } - if (typeof serverInfo?.private_api === "boolean") { - runtime.log?.( - `[${account.accountId}] BlueBubbles Private API ${serverInfo.private_api ? "enabled" : "disabled"}`, - ); - } - - const target: WebhookTarget = { - account, - config, - runtime, - core, - path, - statusSink, - }; - const unregister = registerBlueBubblesWebhookTarget(target); - - return await new Promise((resolve) => { - const stop = () => { - unregister(); - resolve(); - }; - - if (abortSignal?.aborted) { - stop(); - return; - } - - abortSignal?.addEventListener("abort", stop, { once: true }); - runtime.log?.( - `[${account.accountId}] BlueBubbles webhook listening on ${normalizeWebhookPath(path)}`, - ); - - // Kick off a catchup pass for messages delivered while the webhook - // target wasn't reachable. Fire-and-forget; the catchup runs through the - // same processMessage path webhooks use, and #66230's inbound dedupe - // drops any GUID that was already handled, so this is safe even if a - // live webhook raced the startup replay. See #66721. - import("./catchup.js") - .then(({ runBlueBubblesCatchup }) => runBlueBubblesCatchup(target)) - .catch((err) => { - runtime.error?.( - `[${account.accountId}] BlueBubbles catchup: unexpected failure: ${String(err)}`, - ); - }); - }); -} - -export { _resetBlueBubblesShortIdState, resolveBlueBubblesMessageId, resolveWebhookPathFromConfig }; diff --git a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts deleted file mode 100644 index 6da5389144e5..000000000000 --- a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts +++ /dev/null @@ -1,701 +0,0 @@ -import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ResolvedBlueBubblesAccount } from "./accounts.js"; -import { fetchBlueBubblesHistory } from "./history.js"; -import { - createHangingWebhookRequestForTest, - createLoopbackWebhookRequestParamsForTest, - createMockAccount, - createPasswordQueryRequestParamsForTest, - createProtectedWebhookAccountForTest, - createRemoteWebhookRequestParamsForTest, - createTimestampedNewMessagePayloadForTest, - createWebhookDispatchForTest, - dispatchWebhookPayloadForTest, - expectWebhookRequestStatusForTest, - expectWebhookStatusForTest, - LOOPBACK_REMOTE_ADDRESSES_FOR_TEST, - setupWebhookTargetForTest, - setupWebhookTargetsForTest, - trackWebhookRegistrationForTest, - type WebhookRequestParams, -} from "./monitor.webhook.test-helpers.js"; -import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js"; -import { createBlueBubblesFetchGuardPassthroughInstaller } from "./test-harness.js"; -import { - createBlueBubblesMonitorTestRuntime, - EMPTY_DISPATCH_RESULT, - resetBlueBubblesMonitorTestState, - type DispatchReplyParams, -} from "./test-support/monitor-test-support.js"; -import { _setFetchGuardForTesting } from "./types.js"; - -const { TEST_WEBHOOK_RATE_LIMIT_MAX_REQUESTS } = vi.hoisted(() => ({ - TEST_WEBHOOK_RATE_LIMIT_MAX_REQUESTS: 3, -})); -const TEST_WEBHOOK_BODY_TIMEOUT_MS = 1; - -// Mock dependencies -vi.mock("./send.js", () => ({ - resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"), - sendMessageBlueBubbles: vi.fn().mockResolvedValue({ messageId: "msg-123" }), -})); - -vi.mock("./chat.js", () => ({ - markBlueBubblesChatRead: vi.fn().mockResolvedValue(undefined), - sendBlueBubblesTyping: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("./attachments.js", () => ({ - downloadBlueBubblesAttachment: vi.fn().mockResolvedValue({ - buffer: Buffer.from("test"), - contentType: "image/jpeg", - }), -})); - -vi.mock("./reactions.js", () => ({ - normalizeBlueBubblesReactionInput: vi.fn((emoji: string, remove?: boolean) => - remove ? `-${emoji}` : emoji, - ), - sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("./history.js", () => ({ - fetchBlueBubblesHistory: vi.fn().mockResolvedValue({ entries: [], resolved: true }), -})); - -vi.mock("./webhook-ingress.js", async () => { - const actual = - await vi.importActual("./webhook-ingress.js"); - return { - ...actual, - WEBHOOK_RATE_LIMIT_DEFAULTS: { - ...actual.WEBHOOK_RATE_LIMIT_DEFAULTS, - maxRequests: TEST_WEBHOOK_RATE_LIMIT_MAX_REQUESTS, - }, - readWebhookBodyOrReject: (params: Parameters[0]) => - actual.readWebhookBodyOrReject({ - ...params, - timeoutMs: TEST_WEBHOOK_BODY_TIMEOUT_MS, - }), - }; -}); - -afterAll(() => { - vi.doUnmock("./send.js"); - vi.doUnmock("./chat.js"); - vi.doUnmock("./attachments.js"); - vi.doUnmock("./reactions.js"); - vi.doUnmock("./history.js"); - vi.doUnmock("./webhook-ingress.js"); - vi.resetModules(); -}); - -// Mock runtime -const mockEnqueueSystemEvent = vi.fn(); -const mockBuildPairingReply = vi.fn(() => "Pairing code: TESTCODE"); -const mockReadAllowFromStore = vi.fn().mockResolvedValue([]); -const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "TESTCODE", created: true }); -const DEFAULT_RESOLVED_AGENT_ROUTE: ReturnType< - PluginRuntime["channel"]["routing"]["resolveAgentRoute"] -> = { - agentId: "main", - channel: "bluebubbles", - accountId: "default", - sessionKey: "agent:main:bluebubbles:dm:+15551234567", - mainSessionKey: "agent:main:main", - lastRoutePolicy: "main", - matchedBy: "default", -}; -const mockResolveAgentRoute = vi.fn(() => DEFAULT_RESOLVED_AGENT_ROUTE); -const mockBuildMentionRegexes = vi.fn(() => [/\bbert\b/i]); -const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) => - regexes.some((r) => r.test(text)), -); -const mockMatchesMentionWithExplicit = vi.fn( - (params: { text: string; mentionRegexes: RegExp[]; explicitWasMentioned?: boolean }) => { - if (params.explicitWasMentioned) { - return true; - } - return params.mentionRegexes.some((regex) => regex.test(params.text)); - }, -); -const mockResolveRequireMention = vi.fn(() => false); -const mockResolveGroupPolicy = vi.fn(() => ({ - allowlistEnabled: false, - allowed: true, -})); -const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn( - async (_params: DispatchReplyParams) => EMPTY_DISPATCH_RESULT, -); -const mockHasControlCommand = vi.fn(() => false); -const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false); -const mockSaveMediaBuffer = vi.fn().mockResolvedValue({ - id: "test-media.jpg", - path: "/tmp/test-media.jpg", - size: Buffer.byteLength("test"), - contentType: "image/jpeg", -}); -const mockResolveStorePath = vi.fn(() => "/tmp/sessions.json"); -const mockReadSessionUpdatedAt = vi.fn(() => undefined); -const mockResolveEnvelopeFormatOptions = vi.fn(() => ({})); -const mockFormatAgentEnvelope = vi.fn((opts: { body: string }) => opts.body); -const mockFormatInboundEnvelope = vi.fn((opts: { body: string }) => opts.body); -const mockChunkMarkdownText = vi.fn((text: string) => [text]); -const mockChunkByNewline = vi.fn((text: string) => (text ? [text] : [])); -const mockChunkTextWithMode = vi.fn((text: string) => (text ? [text] : [])); -const mockChunkMarkdownTextWithMode = vi.fn((text: string) => (text ? [text] : [])); -const mockResolveChunkMode = vi.fn(() => "length" as const); -const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory); -const mockFetch = vi.fn(); -const TEST_WEBHOOK_PASSWORD = "secret-token"; - -function createMockRuntime(): PluginRuntime { - return createBlueBubblesMonitorTestRuntime({ - enqueueSystemEvent: mockEnqueueSystemEvent, - chunkMarkdownText: mockChunkMarkdownText, - chunkByNewline: mockChunkByNewline, - chunkMarkdownTextWithMode: mockChunkMarkdownTextWithMode, - chunkTextWithMode: mockChunkTextWithMode, - resolveChunkMode: mockResolveChunkMode, - hasControlCommand: mockHasControlCommand, - dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher, - formatAgentEnvelope: mockFormatAgentEnvelope, - formatInboundEnvelope: mockFormatInboundEnvelope, - resolveEnvelopeFormatOptions: mockResolveEnvelopeFormatOptions, - resolveAgentRoute: mockResolveAgentRoute, - buildPairingReply: mockBuildPairingReply, - readAllowFromStore: mockReadAllowFromStore, - upsertPairingRequest: mockUpsertPairingRequest, - saveMediaBuffer: mockSaveMediaBuffer, - resolveStorePath: mockResolveStorePath, - readSessionUpdatedAt: mockReadSessionUpdatedAt, - buildMentionRegexes: mockBuildMentionRegexes, - matchesMentionPatterns: mockMatchesMentionPatterns, - matchesMentionWithExplicit: mockMatchesMentionWithExplicit, - resolveGroupPolicy: mockResolveGroupPolicy, - resolveRequireMention: mockResolveRequireMention, - resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers, - }); -} - -describe("BlueBubbles webhook monitor", () => { - let unregister: () => void; - const installFetchGuardPassthrough = createBlueBubblesFetchGuardPassthroughInstaller(); - - beforeEach(() => { - vi.stubGlobal("fetch", mockFetch); - // See monitor.test.ts for rationale — BlueBubblesClient routes every BB - // API call through the SSRF guard now. (#34749, #59722) - installFetchGuardPassthrough(); - mockFetch.mockReset(); - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }); - resetBlueBubblesMonitorTestState({ - createRuntime: createMockRuntime, - fetchHistoryMock: mockFetchBlueBubblesHistory, - readAllowFromStoreMock: mockReadAllowFromStore, - upsertPairingRequestMock: mockUpsertPairingRequest, - resolveRequireMentionMock: mockResolveRequireMention, - hasControlCommandMock: mockHasControlCommand, - resolveCommandAuthorizedFromAuthorizersMock: mockResolveCommandAuthorizedFromAuthorizers, - buildMentionRegexesMock: mockBuildMentionRegexes, - }); - }); - - afterEach(() => { - unregister?.(); - vi.unstubAllGlobals(); - _setFetchGuardForTesting(null); - }); - - function setupWebhookTarget(params?: { - account?: ResolvedBlueBubblesAccount; - config?: OpenClawConfig; - core?: PluginRuntime; - statusSink?: (event: unknown) => void; - }) { - const registration = trackWebhookRegistrationForTest( - setupWebhookTargetForTest({ - createCore: createMockRuntime, - core: params?.core, - account: params?.account, - config: params?.config, - statusSink: params?.statusSink, - }), - (nextUnregister) => { - unregister = nextUnregister; - }, - ); - return { - account: registration.account, - config: registration.config, - core: registration.core, - }; - } - - function setupProtectedWebhookTarget(password = TEST_WEBHOOK_PASSWORD) { - return setupWebhookTargetAccount(createProtectedWebhookTarget(password).account); - } - - function setupPasswordlessWebhookTarget() { - return setupWebhookTargetAccount(createPasswordlessWebhookTarget().account); - } - - function setupWebhookTargetAccount(account: ResolvedBlueBubblesAccount) { - setupWebhookTarget({ account }); - return account; - } - - function createWebhookTarget( - account: ResolvedBlueBubblesAccount, - statusSink: (event: unknown) => void = vi.fn(), - ) { - return { account, statusSink }; - } - - function createProtectedWebhookTarget(password = TEST_WEBHOOK_PASSWORD) { - return createWebhookTarget(createProtectedWebhookAccountForTest(password)); - } - - function createPasswordlessWebhookTarget() { - return createWebhookTarget(createMockAccount({ password: undefined })); - } - - function createProtectedPasswordQueryRequestParams(password = TEST_WEBHOOK_PASSWORD) { - return createPasswordQueryRequestParamsForTest({ password }); - } - - async function expectWebhookRequestStatusWithSetup( - setup: () => void, - params: WebhookRequestParams, - expectedStatus: number, - expectedBody?: string, - ) { - setup(); - return expectWebhookRequestStatusForTest(params, expectedStatus, expectedBody); - } - - async function dispatchWebhookPayloadWithSetup(setup: () => void, payload: unknown) { - setup(); - return dispatchWebhookPayloadForTest({ body: payload }); - } - - async function expectProtectedPasswordQueryRequestStatus( - expectedStatus: number, - password = TEST_WEBHOOK_PASSWORD, - ) { - return expectWebhookRequestStatusForTest( - createProtectedPasswordQueryRequestParams(password), - expectedStatus, - ); - } - - async function expectProtectedWebhookRequestStatus( - params: WebhookRequestParams, - expectedStatus: number, - expectedBody?: string, - ) { - return expectWebhookRequestStatusWithSetup( - () => { - setupProtectedWebhookTarget(); - }, - params, - expectedStatus, - expectedBody, - ); - } - - async function expectRegisteredWebhookRequestStatus( - params: WebhookRequestParams, - expectedStatus: number, - expectedBody?: string, - ) { - return expectWebhookRequestStatusWithSetup( - () => { - setupWebhookTarget(); - }, - params, - expectedStatus, - expectedBody, - ); - } - - async function dispatchRegisteredWebhookPayload(payload: unknown) { - return dispatchWebhookPayloadWithSetup(() => { - setupWebhookTarget(); - }, payload); - } - - async function expectLoopbackWebhookRequestStatus( - remoteAddress: (typeof LOOPBACK_REMOTE_ADDRESSES_FOR_TEST)[number], - expectedStatus: number, - overrides?: Omit, - ) { - return expectWebhookRequestStatusForTest( - createLoopbackWebhookRequestParamsForTest(remoteAddress, { overrides }), - expectedStatus, - ); - } - - async function expectProtectedLoopbackWebhookRequestStatus( - remoteAddress: (typeof LOOPBACK_REMOTE_ADDRESSES_FOR_TEST)[number], - expectedStatus: number, - overrides?: Omit, - ) { - setupProtectedWebhookTarget(); - return expectLoopbackWebhookRequestStatus(remoteAddress, expectedStatus, overrides); - } - - async function expectPasswordlessLoopbackWebhookRequestStatus( - remoteAddress: (typeof LOOPBACK_REMOTE_ADDRESSES_FOR_TEST)[number], - expectedStatus: number, - overrides?: Omit, - ) { - setupPasswordlessWebhookTarget(); - return expectLoopbackWebhookRequestStatus(remoteAddress, expectedStatus, overrides); - } - - function registerWebhookTargets( - params: Array<{ - account: ResolvedBlueBubblesAccount; - statusSink?: (event: unknown) => void; - }>, - ) { - trackWebhookRegistrationForTest( - setupWebhookTargetsForTest({ - createCore: createMockRuntime, - accounts: params, - }), - (nextUnregister) => { - unregister = nextUnregister; - }, - ); - } - - describe("webhook parsing + auth handling", () => { - it("rejects non-POST requests", async () => { - await expectRegisteredWebhookRequestStatus({ method: "GET" }, 405); - }); - - it("accepts POST requests with valid JSON payload", async () => { - const payload = createTimestampedNewMessagePayloadForTest(); - await expectRegisteredWebhookRequestStatus({ body: payload }, 200, "ok"); - }); - - it("rejects requests with invalid JSON", async () => { - await expectRegisteredWebhookRequestStatus({ body: "invalid json {{" }, 400); - }); - - it("accepts URL-encoded payload wrappers", async () => { - const payload = createTimestampedNewMessagePayloadForTest(); - const encodedBody = new URLSearchParams({ - payload: JSON.stringify(payload), - }).toString(); - await expectRegisteredWebhookRequestStatus({ body: encodedBody }, 200, "ok"); - }); - - it("returns 408 when request body times out (Slow-Loris protection)", async () => { - setupWebhookTarget(); - - // Create a request that never sends data or ends (simulates slow-loris). - const { req, destroyMock } = createHangingWebhookRequestForTest(); - - const { res, handledPromise } = createWebhookDispatchForTest(req); - - const handled = await handledPromise; - expect(handled).toBe(true); - expect(res.statusCode).toBe(408); - expect(destroyMock).toHaveBeenCalled(); - }); - - it("rejects unauthorized requests before reading the body", async () => { - setupProtectedWebhookTarget(); - const { req } = createHangingWebhookRequestForTest( - "/bluebubbles-webhook?password=wrong-token", - ); - const onSpy = vi.spyOn(req, "on"); - await expectWebhookStatusForTest(req, 401); - expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function)); - }); - - it("authenticates via password query parameter", async () => { - await expectProtectedWebhookRequestStatus(createProtectedPasswordQueryRequestParams(), 200); - }); - - it("authenticates via x-password header", async () => { - await expectProtectedWebhookRequestStatus( - createRemoteWebhookRequestParamsForTest({ - overrides: { - headers: { "x-password": TEST_WEBHOOK_PASSWORD }, // pragma: allowlist secret - }, - }), - 200, - ); - }); - - it("rejects unauthorized requests with wrong password", async () => { - await expectProtectedWebhookRequestStatus( - createProtectedPasswordQueryRequestParams("wrong-token"), - 401, - ); - }); - - it("rejects unresolved SecretRef webhook passwords without crashing", async () => { - setupWebhookTarget({ - account: createMockAccount({ - password: { source: "exec", provider: "vault", id: "bluebubbles/webhook" } as never, - }), - }); - - await expectProtectedPasswordQueryRequestStatus(401); - }); - - it("rate limits repeated invalid password guesses from the same client", async () => { - setupWebhookTarget({ - account: createMockAccount({ - password: "99999999", - }), - }); - - let saw429 = false; - for (let i = 0; i < TEST_WEBHOOK_RATE_LIMIT_MAX_REQUESTS + 4; i += 1) { - const candidate = String(i).padStart(8, "0"); - const { res } = await dispatchWebhookPayloadForTest( - createPasswordQueryRequestParamsForTest({ - password: candidate, - body: createTimestampedNewMessagePayloadForTest({ - guid: `msg-${i}`, - text: `hello ${i}`, - }), - remoteAddress: "192.168.1.100", - }), - ); - - if (res.statusCode === 429) { - saw429 = true; - break; - } - - expect(res.statusCode).toBe(401); - } - - expect(saw429).toBe(true); - expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("keeps forwarded clients behind configured trusted proxies in separate auth buckets", async () => { - setupWebhookTarget({ - account: createMockAccount({ - password: "99999999", - }), - config: { - gateway: { - trustedProxies: ["10.0.0.0/8"], - }, - } as OpenClawConfig, - }); - - let saw429 = false; - for (let i = 0; i < TEST_WEBHOOK_RATE_LIMIT_MAX_REQUESTS + 4; i += 1) { - const candidate = String(i).padStart(8, "0"); - const { res } = await dispatchWebhookPayloadForTest( - createPasswordQueryRequestParamsForTest({ - password: candidate, - body: createTimestampedNewMessagePayloadForTest({ - guid: `proxy-msg-${i}`, - text: `hello proxy ${i}`, - }), - remoteAddress: "10.0.0.5", - overrides: { - headers: { - host: "localhost", - "x-forwarded-for": "203.0.113.10", - }, - }, - }), - ); - - if (res.statusCode === 429) { - saw429 = true; - break; - } - - expect(res.statusCode).toBe(401); - } - - expect(saw429).toBe(true); - - await expectWebhookRequestStatusForTest( - createPasswordQueryRequestParamsForTest({ - password: "wrong-pass", - body: createTimestampedNewMessagePayloadForTest({ - guid: "proxy-msg-other-client", - text: "hello other proxy client", - }), - remoteAddress: "10.0.0.5", - overrides: { - headers: { - host: "localhost", - "x-forwarded-for": "203.0.113.11", - }, - }, - }), - 401, - ); - }); - - it("keeps real-ip fallback clients behind trusted proxies in separate auth buckets", async () => { - setupWebhookTarget({ - account: createMockAccount({ - password: "99999999", - }), - config: { - gateway: { - trustedProxies: ["10.0.0.0/8"], - allowRealIpFallback: true, - }, - } as OpenClawConfig, - }); - - let saw429 = false; - for (let i = 0; i < TEST_WEBHOOK_RATE_LIMIT_MAX_REQUESTS + 4; i += 1) { - const candidate = String(i).padStart(8, "0"); - const { res } = await dispatchWebhookPayloadForTest( - createPasswordQueryRequestParamsForTest({ - password: candidate, - body: createTimestampedNewMessagePayloadForTest({ - guid: `real-ip-msg-${i}`, - text: `hello real ip ${i}`, - }), - remoteAddress: "10.0.0.5", - overrides: { - headers: { - host: "localhost", - "x-real-ip": "203.0.113.10", - }, - }, - }), - ); - - if (res.statusCode === 429) { - saw429 = true; - break; - } - - expect(res.statusCode).toBe(401); - } - - expect(saw429).toBe(true); - - await expectWebhookRequestStatusForTest( - createPasswordQueryRequestParamsForTest({ - password: "wrong-pass", - body: createTimestampedNewMessagePayloadForTest({ - guid: "real-ip-msg-other-client", - text: "hello other real ip client", - }), - remoteAddress: "10.0.0.5", - overrides: { - headers: { - host: "localhost", - "x-real-ip": "203.0.113.11", - }, - }, - }), - 401, - ); - }); - - it("rejects ambiguous routing when multiple targets match the same password", async () => { - const targetA = createProtectedWebhookTarget(); - const targetB = createProtectedWebhookTarget(); - registerWebhookTargets([targetA, targetB]); - - await expectProtectedPasswordQueryRequestStatus(401); - expect(targetA.statusSink).not.toHaveBeenCalled(); - expect(targetB.statusSink).not.toHaveBeenCalled(); - }); - - it("ignores targets without passwords when a password-authenticated target matches", async () => { - const strictTarget = createProtectedWebhookTarget(); - const passwordlessTarget = createPasswordlessWebhookTarget(); - registerWebhookTargets([strictTarget, passwordlessTarget]); - - await expectProtectedPasswordQueryRequestStatus(200); - expect(strictTarget.statusSink).toHaveBeenCalledTimes(1); - expect(passwordlessTarget.statusSink).not.toHaveBeenCalled(); - }); - - it("requires authentication for loopback requests when password is configured", async () => { - for (const remoteAddress of LOOPBACK_REMOTE_ADDRESSES_FOR_TEST) { - await expectProtectedLoopbackWebhookRequestStatus(remoteAddress, 401); - } - }); - - it("rejects targets without passwords for loopback and proxied-looking requests", async () => { - const headerVariants: Record[] = [ - { host: "localhost" }, - { host: "localhost", "x-forwarded-for": "203.0.113.10" }, - { host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" }, - ]; - for (const headers of headerVariants) { - await expectPasswordlessLoopbackWebhookRequestStatus("127.0.0.1", 401, { headers }); - } - }); - - it("ignores unregistered webhook paths", async () => { - const { handled } = await dispatchWebhookPayloadForTest({ - url: "/unregistered-path", - }); - - expect(handled).toBe(false); - }); - - it("parses chatId when provided as a string (webhook variant)", async () => { - const { resolveChatGuidForTarget } = await import("./send.js"); - vi.mocked(resolveChatGuidForTarget).mockClear(); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "hello from group", - isGroup: true, - chatId: "123", - }); - - await dispatchRegisteredWebhookPayload(payload); - - expect(resolveChatGuidForTarget).toHaveBeenCalledWith( - expect.objectContaining({ - target: { kind: "chat_id", chatId: 123 }, - }), - ); - }); - - it("extracts chatGuid from nested chat object fields (webhook variant)", async () => { - const { sendMessageBlueBubbles, resolveChatGuidForTarget } = await import("./send.js"); - vi.mocked(sendMessageBlueBubbles).mockClear(); - vi.mocked(resolveChatGuidForTarget).mockClear(); - - mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { - await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" }); - return EMPTY_DISPATCH_RESULT; - }); - - const payload = createTimestampedNewMessagePayloadForTest({ - text: "hello from group", - isGroup: true, - chat: { chatGuid: "iMessage;+;chat123456" }, - }); - - await dispatchRegisteredWebhookPayload(payload); - - expect(resolveChatGuidForTarget).not.toHaveBeenCalled(); - expect(sendMessageBlueBubbles).toHaveBeenCalledWith( - "chat_guid:iMessage;+;chat123456", - expect.any(String), - expect.any(Object), - ); - }); - }); -}); diff --git a/extensions/bluebubbles/src/monitor.webhook.test-helpers.ts b/extensions/bluebubbles/src/monitor.webhook.test-helpers.ts deleted file mode 100644 index 4bc8ac865dc5..000000000000 --- a/extensions/bluebubbles/src/monitor.webhook.test-helpers.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { EventEmitter } from "node:events"; -import type { IncomingMessage, ServerResponse } from "node:http"; -import { expect, vi, type Mock } from "vitest"; -import type { ResolvedBlueBubblesAccount } from "./accounts.js"; -import { handleBlueBubblesWebhookRequest } from "./monitor.js"; -import { registerBlueBubblesWebhookTarget } from "./monitor.js"; -import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js"; -import { setBlueBubblesRuntime } from "./runtime.js"; - -export type WebhookRequestParams = { - method?: string; - url?: string; - body?: unknown; - headers?: Record; - remoteAddress?: string; -}; - -export const LOOPBACK_REMOTE_ADDRESSES_FOR_TEST = ["127.0.0.1", "::1", "::ffff:127.0.0.1"] as const; -type UnknownMock = Mock<(...args: unknown[]) => unknown>; -type HangingWebhookRequestForTest = { - req: IncomingMessage; - destroyMock: UnknownMock; -}; - -export function createMockAccount( - overrides: Partial = {}, -): ResolvedBlueBubblesAccount { - return { - accountId: "default", - enabled: true, - configured: true, - config: { - serverUrl: "http://localhost:1234", - password: "test-password", - dmPolicy: "open", - groupPolicy: "open", - allowFrom: ["*"], - groupAllowFrom: [], - ...overrides, - }, - }; -} - -export function createProtectedWebhookAccountForTest(password = "test-password") { - return createMockAccount({ password }); -} - -export function createNewMessagePayloadForTest(dataOverrides: Record = {}) { - return { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - ...dataOverrides, - }, - }; -} - -export function createTimestampedNewMessagePayloadForTest( - dataOverrides: Record = {}, -) { - return createNewMessagePayloadForTest({ - ...dataOverrides, - date: Date.now(), - }); -} - -function createMessageReactionPayloadForTest(dataOverrides: Record = {}) { - return { - type: "message-reaction", - data: { - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - associatedMessageGuid: "msg-original-123", - associatedMessageType: 2000, - ...dataOverrides, - }, - }; -} - -export function createTimestampedMessageReactionPayloadForTest( - dataOverrides: Record = {}, -) { - return createMessageReactionPayloadForTest({ - ...dataOverrides, - date: Date.now(), - }); -} - -export function createMockRequest( - method: string, - url: string, - body: unknown, - headers: Record = {}, - remoteAddress = "127.0.0.1", -): IncomingMessage { - if (headers.host === undefined) { - headers.host = "localhost"; - } - const parsedUrl = new URL(url, "http://localhost"); - const hasAuthQuery = parsedUrl.searchParams.has("guid") || parsedUrl.searchParams.has("password"); - const hasAuthHeader = - headers["x-guid"] !== undefined || - headers["x-password"] !== undefined || - headers["x-bluebubbles-guid"] !== undefined || - headers.authorization !== undefined; - if (!hasAuthQuery && !hasAuthHeader) { - parsedUrl.searchParams.set("password", "test-password"); - } - - const req = new EventEmitter() as IncomingMessage; - req.method = method; - req.url = `${parsedUrl.pathname}${parsedUrl.search}`; - req.headers = headers; - (req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress }; - - // Emit body data after a microtask. - void Promise.resolve().then(() => { - const bodyStr = typeof body === "string" ? body : JSON.stringify(body); - req.emit("data", Buffer.from(bodyStr)); - req.emit("end"); - }); - - return req; -} - -function createMockRequestForTest(params: WebhookRequestParams = {}): IncomingMessage { - return createMockRequest( - params.method ?? "POST", - params.url ?? "/bluebubbles-webhook", - params.body ?? {}, - params.headers, - params.remoteAddress, - ); -} - -export function createRemoteWebhookRequestParamsForTest( - params: { - body?: unknown; - remoteAddress?: string; - overrides?: WebhookRequestParams; - } = {}, -): WebhookRequestParams { - return { - body: params.body ?? createNewMessagePayloadForTest(), - remoteAddress: params.remoteAddress ?? "192.168.1.100", - ...params.overrides, - }; -} - -export function createPasswordQueryRequestParamsForTest( - params: { - body?: unknown; - password?: string; - remoteAddress?: string; - overrides?: Omit; - } = {}, -): WebhookRequestParams { - return createRemoteWebhookRequestParamsForTest({ - body: params.body, - remoteAddress: params.remoteAddress, - overrides: { - url: `/bluebubbles-webhook?password=${params.password ?? "test-password"}`, - ...params.overrides, - }, - }); -} - -export function createLoopbackWebhookRequestParamsForTest( - remoteAddress: (typeof LOOPBACK_REMOTE_ADDRESSES_FOR_TEST)[number], - params: { - body?: unknown; - overrides?: Omit; - } = {}, -): WebhookRequestParams { - return { - body: params.body ?? createNewMessagePayloadForTest(), - remoteAddress, - ...params.overrides, - }; -} - -export function createHangingWebhookRequestForTest( - url = "/bluebubbles-webhook?password=test-password", - remoteAddress = "127.0.0.1", -): HangingWebhookRequestForTest { - const req = new EventEmitter() as IncomingMessage; - const destroyMock = vi.fn(); - req.method = "POST"; - req.url = url; - req.headers = {}; - req.destroy = destroyMock as unknown as IncomingMessage["destroy"]; - (req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress }; - return { req, destroyMock }; -} - -function createMockResponse(): ServerResponse & { body: string; statusCode: number } { - const res = { - statusCode: 200, - body: "", - setHeader: vi.fn(), - end: vi.fn((data?: string) => { - res.body = data ?? ""; - }), - } as unknown as ServerResponse & { body: string; statusCode: number }; - return res; -} - -async function flushAsync() { - for (let i = 0; i < 2; i += 1) { - await new Promise((resolve) => setImmediate(resolve)); - } -} - -export function createWebhookDispatchForTest(req: IncomingMessage) { - const res = createMockResponse(); - const handledPromise = handleBlueBubblesWebhookRequest(req, res); - return { res, handledPromise }; -} - -export async function dispatchWebhookRequestForTest( - req: IncomingMessage, - options: { flushAsyncAfter?: boolean } = {}, -) { - const { res, handledPromise } = createWebhookDispatchForTest(req); - const handled = await handledPromise; - if (options.flushAsyncAfter) { - await flushAsync(); - } - return { handled, res }; -} - -export async function dispatchWebhookPayloadForTest(params: WebhookRequestParams = {}) { - const req = createMockRequestForTest(params); - return dispatchWebhookRequestForTest(req, { flushAsyncAfter: true }); -} - -export async function expectWebhookStatusForTest( - req: IncomingMessage, - expectedStatus: number, - expectedBody?: string, -) { - const { res, handled } = await dispatchWebhookRequestForTest(req); - expect(handled).toBe(true); - expect(res.statusCode).toBe(expectedStatus); - if (expectedBody !== undefined) { - expect(res.body).toBe(expectedBody); - } - return res; -} - -export async function expectWebhookRequestStatusForTest( - params: WebhookRequestParams, - expectedStatus: number, - expectedBody?: string, -) { - return expectWebhookStatusForTest(createMockRequestForTest(params), expectedStatus, expectedBody); -} - -export function trackWebhookRegistrationForTest void }>( - registration: T, - setUnregister: (unregister: () => void) => void, -) { - setUnregister(registration.unregister); - return registration; -} - -function registerWebhookTargetForTest(params: { - core: PluginRuntime; - account?: ResolvedBlueBubblesAccount; - config?: OpenClawConfig; - path?: string; - statusSink?: (event: unknown) => void; - runtime?: { - log: (...args: unknown[]) => unknown; - error: (...args: unknown[]) => unknown; - }; -}) { - setBlueBubblesRuntime(params.core); - - return registerBlueBubblesWebhookTarget({ - account: params.account ?? createMockAccount(), - config: params.config ?? {}, - runtime: params.runtime ?? { log: vi.fn(), error: vi.fn() }, - core: params.core, - path: params.path ?? "/bluebubbles-webhook", - statusSink: params.statusSink, - }); -} - -function registerWebhookTargetsForTest(params: { - core: PluginRuntime; - accounts: Array<{ - account: ResolvedBlueBubblesAccount; - statusSink?: (event: unknown) => void; - }>; - config?: OpenClawConfig; - path?: string; - runtime?: { - log: (...args: unknown[]) => unknown; - error: (...args: unknown[]) => unknown; - }; -}) { - return params.accounts.map(({ account, statusSink }) => - registerWebhookTargetForTest({ - core: params.core, - account, - config: params.config, - path: params.path, - runtime: params.runtime, - statusSink, - }), - ); -} - -export function setupWebhookTargetForTest(params: { - createCore: () => PluginRuntime; - core?: PluginRuntime; - account?: ResolvedBlueBubblesAccount; - config?: OpenClawConfig; - path?: string; - statusSink?: (event: unknown) => void; - runtime?: { - log: (...args: unknown[]) => unknown; - error: (...args: unknown[]) => unknown; - }; -}) { - const account = params.account ?? createMockAccount(); - const config = params.config ?? {}; - const core = params.core ?? params.createCore(); - const unregister = registerWebhookTargetForTest({ - core, - account, - config, - path: params.path, - statusSink: params.statusSink, - runtime: params.runtime, - }); - return { account, config, core, unregister }; -} - -export function setupWebhookTargetsForTest(params: { - createCore: () => PluginRuntime; - core?: PluginRuntime; - accounts: Array<{ - account: ResolvedBlueBubblesAccount; - statusSink?: (event: unknown) => void; - }>; - config?: OpenClawConfig; - path?: string; - runtime?: { - log: (...args: unknown[]) => unknown; - error: (...args: unknown[]) => unknown; - }; -}) { - const core = params.core ?? params.createCore(); - const unregisterFns = registerWebhookTargetsForTest({ - core, - accounts: params.accounts, - config: params.config, - path: params.path, - runtime: params.runtime, - }); - const unregister = () => { - for (const unregisterFn of unregisterFns) { - unregisterFn(); - } - }; - return { core, unregister }; -} diff --git a/extensions/bluebubbles/src/multipart.ts b/extensions/bluebubbles/src/multipart.ts deleted file mode 100644 index 4e0ce57b2090..000000000000 --- a/extensions/bluebubbles/src/multipart.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; -import { blueBubblesFetchWithTimeout } from "./types.js"; - -function concatUint8Arrays(parts: Uint8Array[]): Uint8Array { - const totalLength = parts.reduce((acc, part) => acc + part.length, 0); - const body = new Uint8Array(totalLength); - let offset = 0; - for (const part of parts) { - body.set(part, offset); - offset += part.length; - } - return body; -} - -export async function postMultipartFormData(params: { - url: string; - boundary: string; - parts: Uint8Array[]; - timeoutMs: number; - ssrfPolicy?: SsrFPolicy; - /** - * Extra headers to merge with the multipart Content-Type. Used to forward - * auth-decorated headers from `BlueBubblesClient` (e.g. `X-BB-Password` - * under header-auth mode). Per-request Content-Type wins over callers so - * the multipart boundary is always authoritative. (Greptile #68234 P1) - */ - extraHeaders?: HeadersInit; -}): Promise { - const body = Buffer.from(concatUint8Arrays(params.parts)); - const headers: Record = {}; - if (params.extraHeaders) { - new Headers(params.extraHeaders).forEach((value, key) => { - headers[key] = value; - }); - } - // Per-request Content-Type wins over callers so the multipart boundary is - // always authoritative. - headers["Content-Type"] = `multipart/form-data; boundary=${params.boundary}`; - return await blueBubblesFetchWithTimeout( - params.url, - { - method: "POST", - headers, - body, - }, - params.timeoutMs, - params.ssrfPolicy, - ); -} - -export async function assertMultipartActionOk(response: Response, action: string): Promise { - if (response.ok) { - return; - } - const errorText = await response.text().catch(() => ""); - throw new Error(`BlueBubbles ${action} failed (${response.status}): ${errorText || "unknown"}`); -} diff --git a/extensions/bluebubbles/src/pairing.ts b/extensions/bluebubbles/src/pairing.ts deleted file mode 100644 index 24a5e252f2e3..000000000000 --- a/extensions/bluebubbles/src/pairing.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing"; -import { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status"; -import type { OpenClawConfig } from "./runtime-api.js"; -import { normalizeBlueBubblesHandle } from "./targets.js"; - -type SendBlueBubblesMessage = ( - id: string, - message: string, - params: { - cfg: OpenClawConfig; - accountId?: string; - }, -) => Promise; - -export function createBlueBubblesPairingText(sendMessageBlueBubbles: SendBlueBubblesMessage) { - return { - idLabel: "bluebubblesSenderId", - message: PAIRING_APPROVED_MESSAGE, - normalizeAllowEntry: createPairingPrefixStripper(/^bluebubbles:/i, normalizeBlueBubblesHandle), - notify: async ({ - cfg, - id, - message, - accountId, - }: { - cfg: OpenClawConfig; - id: string; - message: string; - accountId?: string; - }) => { - await sendMessageBlueBubbles(id, message, { - cfg, - accountId, - }); - }, - }; -} diff --git a/extensions/bluebubbles/src/participant-contact-names.test.ts b/extensions/bluebubbles/src/participant-contact-names.test.ts deleted file mode 100644 index 0f0e132b114c..000000000000 --- a/extensions/bluebubbles/src/participant-contact-names.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { join } from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - enrichBlueBubblesParticipantsWithContactNames, - listBlueBubblesContactsDatabasesForTest, - queryBlueBubblesContactsDatabaseForTest, - resetBlueBubblesParticipantContactNameCacheForTest, - resolveBlueBubblesParticipantContactNamesFromMacOsContactsForTest, -} from "./participant-contact-names.js"; - -describe("enrichBlueBubblesParticipantsWithContactNames", () => { - beforeEach(() => { - resetBlueBubblesParticipantContactNameCacheForTest(); - }); - - it("enriches unnamed phone participants and reuses cached names across formats", async () => { - const resolver = vi.fn( - async (phoneKeys: string[]) => - new Map( - phoneKeys.map((phoneKey) => [ - phoneKey, - phoneKey === "5551234567" ? "Alice Example" : "Bob Example", - ]), - ), - ); - - const first = await enrichBlueBubblesParticipantsWithContactNames( - [{ id: "+1 (555) 123-4567" }, { id: "+15557654321" }], - { - platform: "darwin", - now: () => 1_000, - resolvePhoneNames: resolver, - }, - ); - - expect(first).toEqual([ - { id: "+1 (555) 123-4567", name: "Alice Example" }, - { id: "+15557654321", name: "Bob Example" }, - ]); - expect(resolver).toHaveBeenCalledTimes(1); - expect(resolver).toHaveBeenCalledWith(["5551234567", "5557654321"]); - - const secondResolver = vi.fn(async () => new Map()); - const second = await enrichBlueBubblesParticipantsWithContactNames([{ id: "+15551234567" }], { - platform: "darwin", - now: () => 2_000, - resolvePhoneNames: secondResolver, - }); - - expect(second).toEqual([{ id: "+15551234567", name: "Alice Example" }]); - expect(secondResolver).not.toHaveBeenCalled(); - }); - - it("retries negative cache entries after the short negative ttl expires", async () => { - const firstResolver = vi.fn(async () => new Map()); - const secondResolver = vi.fn(async () => new Map([["5551234567", "Alice Example"]])); - - const first = await enrichBlueBubblesParticipantsWithContactNames([{ id: "+15551234567" }], { - platform: "darwin", - now: () => 1_000, - resolvePhoneNames: firstResolver, - }); - const second = await enrichBlueBubblesParticipantsWithContactNames([{ id: "+15551234567" }], { - platform: "darwin", - now: () => 1_500, - resolvePhoneNames: secondResolver, - }); - const third = await enrichBlueBubblesParticipantsWithContactNames([{ id: "+15551234567" }], { - platform: "darwin", - now: () => 1_000 + 6 * 60 * 1000, - resolvePhoneNames: secondResolver, - }); - - expect(first).toEqual([{ id: "+15551234567" }]); - expect(second).toEqual([{ id: "+15551234567" }]); - expect(third).toEqual([{ id: "+15551234567", name: "Alice Example" }]); - expect(firstResolver).toHaveBeenCalledTimes(1); - expect(secondResolver).toHaveBeenCalledTimes(1); - }); - - it("skips email addresses and keeps existing participant names", async () => { - const resolver = vi.fn(async () => new Map()); - - const participants = await enrichBlueBubblesParticipantsWithContactNames( - [{ id: "alice@example.com" }, { id: "+15551234567", name: "Alice Existing" }], - { - platform: "darwin", - now: () => 1_000, - resolvePhoneNames: resolver, - }, - ); - - expect(participants).toEqual([ - { id: "alice@example.com" }, - { id: "+15551234567", name: "Alice Existing" }, - ]); - expect(resolver).not.toHaveBeenCalled(); - }); - - it("gracefully returns original participants when lookup fails", async () => { - const participants = [{ id: "+15551234567" }, { id: "+15557654321" }]; - - await expect( - enrichBlueBubblesParticipantsWithContactNames(participants, { - platform: "darwin", - now: () => 1_000, - resolvePhoneNames: vi.fn(async () => { - throw new Error("contacts unavailable"); - }), - }), - ).resolves.toBe(participants); - }); - - it("lists contacts databases from the current home directory", async () => { - const expectedSourcesDir = join( - "/Users/tester", - "Library", - "Application Support", - "AddressBook", - "Sources", - ); - const expectedDatabasePath = join(expectedSourcesDir, "source-a", "AddressBook-v22.abcddb"); - const readdir = vi.fn(async () => ["source-a", "source-b"]); - const access = vi.fn(async (path: string) => { - if (path !== expectedDatabasePath) { - throw new Error("missing"); - } - }); - - const databases = await listBlueBubblesContactsDatabasesForTest({ - homeDir: "/Users/tester", - readdir, - access, - }); - - expect(readdir).toHaveBeenCalledWith(expectedSourcesDir); - expect(databases).toEqual([expectedDatabasePath]); - }); - - it("queries only the requested phone keys in sqlite", async () => { - const execFileAsync = vi.fn(async (_file: string, _args: string[], _options: unknown) => ({ - stdout: "5551234567\tAlice Example\n5557654321\tBob Example\n", - stderr: "", - })); - - const rows = await queryBlueBubblesContactsDatabaseForTest( - "/tmp/AddressBook-v22.abcddb", - ["5551234567", "5557654321"], - { execFileAsync }, - ); - - expect(rows).toEqual([ - { phoneKey: "5551234567", name: "Alice Example" }, - { phoneKey: "5557654321", name: "Bob Example" }, - ]); - expect(execFileAsync).toHaveBeenCalledTimes(1); - const sql = execFileAsync.mock.calls[0]?.[1]?.[3]; - expect(sql).toContain("WHERE digits IN ('5551234567', '5557654321')"); - }); - - it("resolves names through the macOS contacts path across multiple databases", async () => { - const readdir = vi.fn(async () => ["source-a", "source-b"]); - const access = vi.fn(async () => undefined); - const execFileAsync = vi - .fn(async (_file: string, _args: string[], _options: unknown) => ({ - stdout: "", - stderr: "", - })) - .mockResolvedValueOnce({ stdout: "5551234567\tAlice Example\n", stderr: "" }) - .mockResolvedValueOnce({ stdout: "5557654321\tBob Example\n", stderr: "" }); - - const resolved = await resolveBlueBubblesParticipantContactNamesFromMacOsContactsForTest( - ["5551234567", "5557654321"], - { - homeDir: "/Users/tester", - readdir, - access, - execFileAsync, - }, - ); - - expect([...resolved.entries()]).toEqual([ - ["5551234567", "Alice Example"], - ["5557654321", "Bob Example"], - ]); - expect(execFileAsync).toHaveBeenCalledTimes(2); - }); - - it("skips contact lookup on non macOS hosts", async () => { - const participants = [{ id: "+15551234567" }]; - - const result = await enrichBlueBubblesParticipantsWithContactNames(participants, { - platform: "linux", - }); - - expect(result).toBe(participants); - }); -}); diff --git a/extensions/bluebubbles/src/participant-contact-names.ts b/extensions/bluebubbles/src/participant-contact-names.ts deleted file mode 100644 index 29b41e347837..000000000000 --- a/extensions/bluebubbles/src/participant-contact-names.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { execFile, type ExecFileOptionsWithStringEncoding } from "node:child_process"; -import { access, readdir } from "node:fs/promises"; -import { join } from "node:path"; -import { promisify } from "node:util"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; -import type { BlueBubblesParticipant } from "./monitor-normalize.js"; - -const execFileAsync = promisify(execFile) as ExecFileRunner; -const CONTACT_NAME_CACHE_TTL_MS = 60 * 60 * 1000; -const NEGATIVE_CONTACT_NAME_CACHE_TTL_MS = 5 * 60 * 1000; -const MAX_PARTICIPANT_CONTACT_NAME_CACHE_ENTRIES = 2048; -const SQLITE_MAX_BUFFER = 8 * 1024 * 1024; -const SQLITE_PHONE_DIGITS_SQL = - "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(p.ZFULLNUMBER, ''), ' ', ''), '(', ''), ')', ''), '-', ''), '+', ''), '.', ''), '\n', ''), '\r', '')"; - -type ContactNameCacheEntry = { - name?: string; - expiresAt: number; -}; - -type ResolvePhoneNamesFn = (phoneKeys: string[]) => Promise>; -type ExecFileRunner = ( - file: string, - args: string[], - options: ExecFileOptionsWithStringEncoding, -) => Promise<{ stdout: string; stderr: string }>; -type ReadDirRunner = (path: string) => Promise; -type AccessRunner = (path: string) => Promise; - -type ParticipantContactNameDeps = { - platform?: NodeJS.Platform; - now?: () => number; - resolvePhoneNames?: ResolvePhoneNamesFn; - homeDir?: string; - readdir?: ReadDirRunner; - access?: AccessRunner; - execFileAsync?: ExecFileRunner; -}; - -type ResolvedParticipantContactNameDeps = { - platform: NodeJS.Platform; - now: () => number; - resolvePhoneNames?: ResolvePhoneNamesFn; - homeDir?: string; - readdir: ReadDirRunner; - access: AccessRunner; - execFileAsync: ExecFileRunner; -}; - -const participantContactNameCache = new Map(); -let participantContactNameDepsForTest: ParticipantContactNameDeps | undefined; - -function normalizePhoneLookupKey(value: string): string | null { - const digits = value.replace(/\D/g, ""); - if (!digits) { - return null; - } - const normalized = digits.length === 11 && digits.startsWith("1") ? digits.slice(1) : digits; - return normalized.length >= 7 ? normalized : null; -} - -function uniqueNormalizedPhoneLookupKeys(phoneKeys: string[]): string[] { - const unique = new Set(); - for (const phoneKey of phoneKeys) { - const normalized = normalizePhoneLookupKey(phoneKey); - if (normalized) { - unique.add(normalized); - } - } - return [...unique]; -} - -function resolveParticipantPhoneLookupKey(participant: BlueBubblesParticipant): string | null { - if (participant.id.includes("@")) { - return null; - } - return normalizePhoneLookupKey(participant.id); -} - -function trimParticipantContactNameCache(now: number): void { - for (const [phoneKey, entry] of participantContactNameCache) { - if (entry.expiresAt <= now) { - participantContactNameCache.delete(phoneKey); - } - } - while (participantContactNameCache.size > MAX_PARTICIPANT_CONTACT_NAME_CACHE_ENTRIES) { - const oldestPhoneKey = participantContactNameCache.keys().next().value; - if (!oldestPhoneKey) { - return; - } - participantContactNameCache.delete(oldestPhoneKey); - } -} - -function readFreshCacheEntry(phoneKey: string, now: number): ContactNameCacheEntry | null { - const cached = participantContactNameCache.get(phoneKey); - if (!cached) { - return null; - } - if (cached.expiresAt <= now) { - participantContactNameCache.delete(phoneKey); - return null; - } - participantContactNameCache.delete(phoneKey); - participantContactNameCache.set(phoneKey, cached); - return cached; -} - -function writeCacheEntry(phoneKey: string, name: string | undefined, now: number): void { - participantContactNameCache.delete(phoneKey); - participantContactNameCache.set(phoneKey, { - name, - expiresAt: now + (name ? CONTACT_NAME_CACHE_TTL_MS : NEGATIVE_CONTACT_NAME_CACHE_TTL_MS), - }); - trimParticipantContactNameCache(now); -} - -function buildAddressBookSourcesDir(homeDir?: string): string | null { - const trimmedHomeDir = homeDir?.trim(); - if (!trimmedHomeDir) { - return null; - } - return join(trimmedHomeDir, "Library", "Application Support", "AddressBook", "Sources"); -} - -async function fileExists( - path: string, - deps: ResolvedParticipantContactNameDeps, -): Promise { - try { - await deps.access(path); - return true; - } catch { - return false; - } -} - -async function listContactsDatabases(deps: ResolvedParticipantContactNameDeps): Promise { - const sourcesDir = buildAddressBookSourcesDir(deps.homeDir); - if (!sourcesDir) { - return []; - } - let entries: string[] = []; - try { - entries = await deps.readdir(sourcesDir); - } catch { - return []; - } - const databases: string[] = []; - for (const entry of entries) { - const dbPath = join(sourcesDir, entry, "AddressBook-v22.abcddb"); - if (await fileExists(dbPath, deps)) { - databases.push(dbPath); - } - } - return databases; -} - -function buildSqlitePhoneKeyList(phoneKeys: string[]): string { - return uniqueNormalizedPhoneLookupKeys(phoneKeys) - .map((phoneKey) => `'${phoneKey}'`) - .join(", "); -} - -async function queryContactsDatabase( - dbPath: string, - phoneKeys: string[], - deps: ResolvedParticipantContactNameDeps, -): Promise> { - const sqlitePhoneKeyList = buildSqlitePhoneKeyList(phoneKeys); - if (!sqlitePhoneKeyList) { - return []; - } - const sql = ` -SELECT digits, name -FROM ( - SELECT - ${SQLITE_PHONE_DIGITS_SQL} AS digits, - TRIM( - CASE - WHEN TRIM(COALESCE(r.ZFIRSTNAME, '') || ' ' || COALESCE(r.ZLASTNAME, '')) != '' - THEN TRIM(COALESCE(r.ZFIRSTNAME, '') || ' ' || COALESCE(r.ZLASTNAME, '')) - ELSE COALESCE(r.ZORGANIZATION, '') - END - ) AS name - FROM ZABCDRECORD r - JOIN ZABCDPHONENUMBER p ON p.ZOWNER = r.Z_PK - WHERE p.ZFULLNUMBER IS NOT NULL -) -WHERE digits IN (${sqlitePhoneKeyList}) - AND name != ''; -`; - const options: ExecFileOptionsWithStringEncoding = { - encoding: "utf8", - maxBuffer: SQLITE_MAX_BUFFER, - }; - const { stdout } = await deps.execFileAsync( - "sqlite3", - ["-separator", "\t", dbPath, sql], - options, - ); - const rows: Array<{ phoneKey: string; name: string }> = []; - for (const line of stdout.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - const [digitsRaw, ...nameParts] = trimmed.split("\t"); - const phoneKey = normalizePhoneLookupKey(digitsRaw ?? ""); - const name = nameParts.join("\t").trim(); - if (!phoneKey || !name) { - continue; - } - rows.push({ phoneKey, name }); - } - return rows; -} - -async function resolvePhoneNamesFromMacOsContacts( - phoneKeys: string[], - deps: ResolvedParticipantContactNameDeps, -): Promise> { - const normalizedPhoneKeys = uniqueNormalizedPhoneLookupKeys(phoneKeys); - if (normalizedPhoneKeys.length === 0) { - return new Map(); - } - const databases = await listContactsDatabases(deps); - if (databases.length === 0) { - return new Map(); - } - - const unresolved = new Set(normalizedPhoneKeys); - const resolved = new Map(); - for (const dbPath of databases) { - let rows: Array<{ phoneKey: string; name: string }> = []; - try { - rows = await queryContactsDatabase(dbPath, [...unresolved], deps); - } catch { - continue; - } - for (const row of rows) { - if (!unresolved.has(row.phoneKey) || resolved.has(row.phoneKey)) { - continue; - } - resolved.set(row.phoneKey, row.name); - unresolved.delete(row.phoneKey); - if (unresolved.size === 0) { - return resolved; - } - } - } - - return resolved; -} - -function resolveLookupDeps(deps?: ParticipantContactNameDeps): ResolvedParticipantContactNameDeps { - const merged = { - ...participantContactNameDepsForTest, - ...deps, - }; - return { - platform: merged.platform ?? process.platform, - now: merged.now ?? (() => Date.now()), - resolvePhoneNames: merged.resolvePhoneNames, - homeDir: merged.homeDir ?? process.env.HOME, - readdir: merged.readdir ?? readdir, - access: merged.access ?? access, - execFileAsync: merged.execFileAsync ?? execFileAsync, - }; -} - -export async function enrichBlueBubblesParticipantsWithContactNames( - participants: BlueBubblesParticipant[] | undefined, - deps?: ParticipantContactNameDeps, -): Promise { - if (!Array.isArray(participants) || participants.length === 0) { - return []; - } - - const resolvedDeps = resolveLookupDeps(deps); - const lookup = - resolvedDeps.resolvePhoneNames ?? - ((phoneKeys: string[]) => resolvePhoneNamesFromMacOsContacts(phoneKeys, resolvedDeps)); - const shouldAttemptLookup = - Boolean(resolvedDeps.resolvePhoneNames) || resolvedDeps.platform === "darwin"; - if (!shouldAttemptLookup) { - return participants; - } - - const nowMs = resolvedDeps.now(); - trimParticipantContactNameCache(nowMs); - const pendingPhoneKeys = new Set(); - const cachedNames = new Map(); - - for (const participant of participants) { - if (participant.name?.trim()) { - continue; - } - const phoneKey = resolveParticipantPhoneLookupKey(participant); - if (!phoneKey) { - continue; - } - const cached = readFreshCacheEntry(phoneKey, nowMs); - if (cached?.name) { - cachedNames.set(phoneKey, cached.name); - continue; - } - if (!cached) { - pendingPhoneKeys.add(phoneKey); - } - } - - if (pendingPhoneKeys.size > 0) { - try { - const resolved = await lookup([...pendingPhoneKeys]); - for (const phoneKey of pendingPhoneKeys) { - const name = normalizeOptionalString(resolved.get(phoneKey)); - writeCacheEntry(phoneKey, name, nowMs); - if (name) { - cachedNames.set(phoneKey, name); - } - } - } catch { - return participants; - } - } - - let didChange = false; - const enriched = participants.map((participant) => { - if (participant.name?.trim()) { - return participant; - } - const phoneKey = resolveParticipantPhoneLookupKey(participant); - if (!phoneKey) { - return participant; - } - const name = cachedNames.get(phoneKey)?.trim(); - if (!name) { - return participant; - } - didChange = true; - return { ...participant, name }; - }); - - return didChange ? enriched : participants; -} - -export async function listBlueBubblesContactsDatabasesForTest( - deps?: ParticipantContactNameDeps, -): Promise { - return listContactsDatabases(resolveLookupDeps(deps)); -} - -export async function queryBlueBubblesContactsDatabaseForTest( - dbPath: string, - phoneKeys: string[], - deps?: ParticipantContactNameDeps, -): Promise> { - return queryContactsDatabase(dbPath, phoneKeys, resolveLookupDeps(deps)); -} - -export async function resolveBlueBubblesParticipantContactNamesFromMacOsContactsForTest( - phoneKeys: string[], - deps?: ParticipantContactNameDeps, -): Promise> { - return resolvePhoneNamesFromMacOsContacts(phoneKeys, resolveLookupDeps(deps)); -} - -export function resetBlueBubblesParticipantContactNameCacheForTest(): void { - participantContactNameCache.clear(); -} - -export function setBlueBubblesParticipantContactDepsForTest( - deps?: ParticipantContactNameDeps, -): void { - participantContactNameDepsForTest = deps; - participantContactNameCache.clear(); -} diff --git a/extensions/bluebubbles/src/probe.ts b/extensions/bluebubbles/src/probe.ts deleted file mode 100644 index 9ad32a8378b4..000000000000 --- a/extensions/bluebubbles/src/probe.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; -import { createBlueBubblesClientFromParts } from "./client.js"; -import type { BaseProbeResult } from "./runtime-api.js"; -import { normalizeSecretInputString } from "./secret-input.js"; - -export type BlueBubblesProbe = BaseProbeResult & { - status?: number | null; -}; - -type BlueBubblesServerInfo = { - os_version?: string; - server_version?: string; - private_api?: boolean; - helper_connected?: boolean; - proxy_service?: string; - detected_icloud?: string; - computer_id?: string; -}; - -/** Cache server info by account ID to avoid repeated API calls. - * Size-capped to prevent unbounded growth (#4948). */ -const MAX_SERVER_INFO_CACHE_SIZE = 64; -const serverInfoCache = new Map(); -const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes - -/** - * Fetch server info from BlueBubbles API and cache it. - * Returns cached result if available and not expired. - */ -export async function fetchBlueBubblesServerInfo(params: { - baseUrl?: string | null; - password?: string | null; - accountId?: string; - timeoutMs?: number; - allowPrivateNetwork?: boolean; -}): Promise { - const baseUrl = normalizeSecretInputString(params.baseUrl); - const password = normalizeSecretInputString(params.password); - if (!baseUrl || !password) { - return null; - } - - const cacheKey = normalizeOptionalString(params.accountId) || "default"; - const cached = serverInfoCache.get(cacheKey); - if (cached && cached.expires > Date.now()) { - return cached.info; - } - - const client = createBlueBubblesClientFromParts({ - baseUrl, - password, - allowPrivateNetwork: params.allowPrivateNetwork === true, - timeoutMs: params.timeoutMs ?? 5000, - }); - try { - const res = await client.getServerInfo({ timeoutMs: params.timeoutMs ?? 5000 }); - if (!res.ok) { - return null; - } - const payload = (await res.json().catch(() => null)) as Record | null; - const data = payload?.data as BlueBubblesServerInfo | undefined; - if (data) { - serverInfoCache.set(cacheKey, { info: data, expires: Date.now() + CACHE_TTL_MS }); - // Evict oldest entries if cache exceeds max size - if (serverInfoCache.size > MAX_SERVER_INFO_CACHE_SIZE) { - const oldest = serverInfoCache.keys().next().value; - if (oldest !== undefined) { - serverInfoCache.delete(oldest); - } - } - } - return data ?? null; - } catch { - return null; - } -} - -/** - * Get cached server info synchronously (for use in describeMessageTool). - * Returns null if not cached or expired. - */ -function getCachedBlueBubblesServerInfo(accountId?: string): BlueBubblesServerInfo | null { - const cacheKey = normalizeOptionalString(accountId) || "default"; - const cached = serverInfoCache.get(cacheKey); - if (cached && cached.expires > Date.now()) { - return cached.info; - } - return null; -} - -/** - * Read cached private API capability for a BlueBubbles account. - * Returns null when capability is unknown (for example, before first probe). - */ -export function getCachedBlueBubblesPrivateApiStatus(accountId?: string): boolean | null { - const info = getCachedBlueBubblesServerInfo(accountId); - if (!info || typeof info.private_api !== "boolean") { - return null; - } - return info.private_api; -} - -export function isBlueBubblesPrivateApiStatusEnabled(status: boolean | null): boolean { - return status === true; -} - -export function isBlueBubblesPrivateApiEnabled(accountId?: string): boolean { - return isBlueBubblesPrivateApiStatusEnabled(getCachedBlueBubblesPrivateApiStatus(accountId)); -} - -/** - * Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number. - */ -function parseMacOSMajorVersion(version?: string | null): number | null { - if (!version) { - return null; - } - const match = /^(\d+)/.exec(version.trim()); - return match ? Number.parseInt(match[1], 10) : null; -} - -/** - * Check if the cached server info indicates macOS 26 or higher. - * Returns false if no cached info is available (fail open for action listing). - */ -export function isMacOS26OrHigher(accountId?: string): boolean { - const info = getCachedBlueBubblesServerInfo(accountId); - if (!info?.os_version) { - return false; - } - const major = parseMacOSMajorVersion(info.os_version); - return major !== null && major >= 26; -} - -export async function probeBlueBubbles(params: { - baseUrl?: string | null; - password?: string | null; - timeoutMs?: number; - allowPrivateNetwork?: boolean; -}): Promise { - const baseUrl = normalizeSecretInputString(params.baseUrl); - const password = normalizeSecretInputString(params.password); - if (!baseUrl) { - return { ok: false, error: "serverUrl not configured" }; - } - if (!password) { - return { ok: false, error: "password not configured" }; - } - const client = createBlueBubblesClientFromParts({ - baseUrl, - password, - allowPrivateNetwork: params.allowPrivateNetwork === true, - timeoutMs: params.timeoutMs, - }); - try { - const res = await client.ping({ timeoutMs: params.timeoutMs }); - if (!res.ok) { - return { ok: false, status: res.status, error: `HTTP ${res.status}` }; - } - return { ok: true, status: res.status }; - } catch (err) { - return { - ok: false, - status: null, - error: formatErrorMessage(err), - }; - } -} diff --git a/extensions/bluebubbles/src/reactions.test.ts b/extensions/bluebubbles/src/reactions.test.ts deleted file mode 100644 index 865707a311da..000000000000 --- a/extensions/bluebubbles/src/reactions.test.ts +++ /dev/null @@ -1,422 +0,0 @@ -import { afterAll, describe, expect, it, vi } from "vitest"; -import { - normalizeBlueBubblesReactionInput, - normalizeBlueBubblesReactionInputStrict, - sendBlueBubblesReaction, -} from "./reactions.js"; -import { installBlueBubblesFetchTestHooks } from "./test-harness.js"; - -vi.mock("./accounts.js", async () => { - const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js"); - return createBlueBubblesAccountsMockModule(); -}); - -const mockFetch = vi.fn(); -const noopPrivateApiStatusMock = { - mockReturnValue: () => {}, -}; - -installBlueBubblesFetchTestHooks({ - mockFetch, - privateApiStatusMock: noopPrivateApiStatusMock, -}); - -afterAll(() => { - vi.doUnmock("./accounts.js"); - vi.resetModules(); -}); - -describe("reactions", () => { - describe("sendBlueBubblesReaction", () => { - async function expectRemovedReaction(emoji: string, expectedReaction = "-love") { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji, - remove: true, - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.reaction).toBe(expectedReaction); - } - - it("throws when chatGuid is empty", async () => { - await expect( - sendBlueBubblesReaction({ - chatGuid: "", - messageGuid: "msg-123", - emoji: "love", - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }), - ).rejects.toThrow("chatGuid"); - }); - - it("throws when messageGuid is empty", async () => { - await expect( - sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "", - emoji: "love", - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }), - ).rejects.toThrow("messageGuid"); - }); - - it("throws when emoji is empty", async () => { - await expect( - sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: "", - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }), - ).rejects.toThrow("emoji or name"); - }); - - it("throws when serverUrl is missing", async () => { - await expect( - sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: "love", - opts: {}, - }), - ).rejects.toThrow("serverUrl is required"); - }); - - it("throws when password is missing", async () => { - await expect( - sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: "love", - opts: { - serverUrl: "http://localhost:1234", - }, - }), - ).rejects.toThrow("password is required"); - }); - - it("falls back to love for unsupported reaction type", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: "👀", - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.reaction).toBe("love"); - }); - - describe("reaction type normalization", () => { - const testCases = [ - { input: "love", expected: "love" }, - { input: "like", expected: "like" }, - { input: "dislike", expected: "dislike" }, - { input: "laugh", expected: "laugh" }, - { input: "emphasize", expected: "emphasize" }, - { input: "question", expected: "question" }, - { input: "heart", expected: "love" }, - { input: "thumbs_up", expected: "like" }, - { input: "thumbs-down", expected: "dislike" }, - { input: "thumbs_down", expected: "dislike" }, - { input: "haha", expected: "laugh" }, - { input: "lol", expected: "laugh" }, - { input: "emphasis", expected: "emphasize" }, - { input: "exclaim", expected: "emphasize" }, - { input: "❤️", expected: "love" }, - { input: "❤", expected: "love" }, - { input: "♥️", expected: "love" }, - { input: "😍", expected: "love" }, - { input: "👍", expected: "like" }, - { input: "👎", expected: "dislike" }, - { input: "😂", expected: "laugh" }, - { input: "🤣", expected: "laugh" }, - { input: "😆", expected: "laugh" }, - { input: "‼️", expected: "emphasize" }, - { input: "‼", expected: "emphasize" }, - { input: "❗", expected: "emphasize" }, - { input: "❓", expected: "question" }, - { input: "❔", expected: "question" }, - { input: "LOVE", expected: "love" }, - { input: "Like", expected: "like" }, - ]; - - for (const { input, expected } of testCases) { - it(`normalizes "${input}" to "${expected}"`, async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: input, - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.reaction).toBe(expected); - }); - } - }); - - it("sends reaction successfully", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesReaction({ - chatGuid: "iMessage;-;+15551234567", - messageGuid: "msg-uuid-123", - emoji: "love", - opts: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }); - - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining("/api/v1/message/react"), - expect.objectContaining({ - method: "POST", - headers: { "Content-Type": "application/json" }, - }), - ); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.chatGuid).toBe("iMessage;-;+15551234567"); - expect(body.selectedMessageGuid).toBe("msg-uuid-123"); - expect(body.reaction).toBe("love"); - expect(body.partIndex).toBe(0); - }); - - it("includes password in URL query", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: "like", - opts: { - serverUrl: "http://localhost:1234", - password: "my-react-password", - }, - }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("password=my-react-password"); - }); - - it("sends reaction removal with dash prefix", async () => { - await expectRemovedReaction("love"); - }); - - it("strips leading dash from emoji when remove flag is set", async () => { - await expectRemovedReaction("-love"); - }); - - it("falls back to removing love for unsupported removal reactions", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: "👀", - remove: true, - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.reaction).toBe("-love"); - }); - - it("uses custom partIndex when provided", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: "laugh", - partIndex: 3, - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.partIndex).toBe(3); - }); - - it("throws on non-ok response", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 400, - text: () => Promise.resolve("Invalid reaction type"), - }); - - await expect( - sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: "like", - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }), - ).rejects.toThrow("reaction failed (400): Invalid reaction type"); - }); - - it("resolves credentials from config", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: "emphasize", - opts: { - cfg: { - channels: { - bluebubbles: { - serverUrl: "http://react-server:7777", - password: "react-pass", - }, - }, - }, - }, - }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("react-server:7777"); - expect(calledUrl).toContain("password=react-pass"); - }); - - it("trims chatGuid and messageGuid", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesReaction({ - chatGuid: " chat-with-spaces ", - messageGuid: " msg-with-spaces ", - emoji: "question", - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.chatGuid).toBe("chat-with-spaces"); - expect(body.selectedMessageGuid).toBe("msg-with-spaces"); - }); - - describe("reaction removal aliases", () => { - it("handles emoji-based removal", async () => { - await expectRemovedReaction("👍", "-like"); - }); - - it("handles text alias removal", async () => { - await expectRemovedReaction("haha", "-laugh"); - }); - }); - }); - - describe("normalizeBlueBubblesReactionInputStrict", () => { - it("maps supported emoji to canonical type", () => { - expect(normalizeBlueBubblesReactionInputStrict("👍")).toBe("like"); - expect(normalizeBlueBubblesReactionInputStrict("❤️")).toBe("love"); - expect(normalizeBlueBubblesReactionInputStrict("😂")).toBe("laugh"); - }); - - it("throws on unsupported input so validators can detect misconfiguration", () => { - expect(() => normalizeBlueBubblesReactionInputStrict("👀")).toThrow( - /Unsupported BlueBubbles reaction/, - ); - expect(() => normalizeBlueBubblesReactionInputStrict("🎉")).toThrow( - /Unsupported BlueBubbles reaction/, - ); - }); - - it("throws on empty input", () => { - expect(() => normalizeBlueBubblesReactionInputStrict("")).toThrow( - /requires an emoji or name/, - ); - expect(() => normalizeBlueBubblesReactionInputStrict(" ")).toThrow( - /requires an emoji or name/, - ); - }); - }); - - describe("normalizeBlueBubblesReactionInput (lenient)", () => { - it("maps supported emoji to canonical type", () => { - expect(normalizeBlueBubblesReactionInput("👍")).toBe("like"); - expect(normalizeBlueBubblesReactionInput("❤️")).toBe("love"); - }); - - it("falls back to love when input is unsupported by iMessage tapback", () => { - expect(normalizeBlueBubblesReactionInput("👀")).toBe("love"); - expect(normalizeBlueBubblesReactionInput("🎉")).toBe("love"); - }); - - it("falls back to -love on unsupported remove", () => { - expect(normalizeBlueBubblesReactionInput("👀", true)).toBe("-love"); - }); - - it("still throws on empty input (strict error bubbles up unchanged)", () => { - // Empty input is a contract error from the caller, not a decorative - // emoji the model picked; we intentionally do not mask it. - expect(() => normalizeBlueBubblesReactionInput("")).toThrow(/requires an emoji or name/); - }); - }); -}); diff --git a/extensions/bluebubbles/src/reactions.ts b/extensions/bluebubbles/src/reactions.ts deleted file mode 100644 index d2b08e1d3910..000000000000 --- a/extensions/bluebubbles/src/reactions.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; -import { createBlueBubblesClient } from "./client.js"; -import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; -import type { OpenClawConfig } from "./runtime-api.js"; - -export type BlueBubblesReactionOpts = { - serverUrl?: string; - password?: string; - accountId?: string; - timeoutMs?: number; - cfg?: OpenClawConfig; -}; - -const REACTION_TYPES = new Set(["love", "like", "dislike", "laugh", "emphasize", "question"]); - -const REACTION_ALIASES = new Map([ - // General - ["heart", "love"], - ["love", "love"], - ["❤", "love"], - ["❤️", "love"], - ["red_heart", "love"], - ["thumbs_up", "like"], - ["thumbsup", "like"], - ["thumbs-up", "like"], - ["thumbsup", "like"], - ["like", "like"], - ["thumb", "like"], - ["ok", "like"], - ["thumbs_down", "dislike"], - ["thumbsdown", "dislike"], - ["thumbs-down", "dislike"], - ["dislike", "dislike"], - ["boo", "dislike"], - ["no", "dislike"], - // Laugh - ["haha", "laugh"], - ["lol", "laugh"], - ["lmao", "laugh"], - ["rofl", "laugh"], - ["😂", "laugh"], - ["🤣", "laugh"], - ["xd", "laugh"], - ["laugh", "laugh"], - // Emphasize / exclaim - ["emphasis", "emphasize"], - ["emphasize", "emphasize"], - ["exclaim", "emphasize"], - ["!!", "emphasize"], - ["‼", "emphasize"], - ["‼️", "emphasize"], - ["❗", "emphasize"], - ["important", "emphasize"], - ["bang", "emphasize"], - // Question - ["question", "question"], - ["?", "question"], - ["❓", "question"], - ["❔", "question"], - ["ask", "question"], - // Apple/Messages names - ["loved", "love"], - ["liked", "like"], - ["disliked", "dislike"], - ["laughed", "laugh"], - ["emphasized", "emphasize"], - ["questioned", "question"], - // Colloquial / informal - ["fire", "love"], - ["🔥", "love"], - ["wow", "emphasize"], - ["!", "emphasize"], - // Edge: generic emoji name forms - ["heart_eyes", "love"], - ["smile", "laugh"], - ["smiley", "laugh"], - ["happy", "laugh"], - ["joy", "laugh"], -]); - -const REACTION_EMOJIS = new Map([ - // Love - ["❤️", "love"], - ["❤", "love"], - ["♥️", "love"], - ["♥", "love"], - ["😍", "love"], - ["💕", "love"], - // Like - ["👍", "like"], - ["👌", "like"], - // Dislike - ["👎", "dislike"], - ["🙅", "dislike"], - // Laugh - ["😂", "laugh"], - ["🤣", "laugh"], - ["😆", "laugh"], - ["😁", "laugh"], - ["😹", "laugh"], - // Emphasize - ["‼️", "emphasize"], - ["‼", "emphasize"], - ["!!", "emphasize"], - ["❗", "emphasize"], - ["❕", "emphasize"], - ["!", "emphasize"], - // Question - ["❓", "question"], - ["❔", "question"], - ["?", "question"], -]); - -const UNSUPPORTED_REACTION_ERROR = "UnsupportedBlueBubblesReaction"; - -/** - * Strict normalizer: throws when the input does not map to a supported - * BlueBubbles reaction type. Use this for validator-style callers that - * need to detect unsupported input (e.g. config sanity checks) rather - * than gracefully substituting a fallback. - */ -export function normalizeBlueBubblesReactionInputStrict(emoji: string, remove?: boolean): string { - const trimmed = emoji.trim(); - if (!trimmed) { - throw new Error("BlueBubbles reaction requires an emoji or name."); - } - let raw = normalizeLowercaseStringOrEmpty(trimmed); - if (raw.startsWith("-")) { - raw = raw.slice(1); - } - const aliased = REACTION_ALIASES.get(raw) ?? raw; - const mapped = REACTION_EMOJIS.get(trimmed) ?? REACTION_EMOJIS.get(raw) ?? aliased; - if (!REACTION_TYPES.has(mapped)) { - const error = new Error(`Unsupported BlueBubbles reaction: ${trimmed}`); - error.name = UNSUPPORTED_REACTION_ERROR; - throw error; - } - return remove ? `-${mapped}` : mapped; -} - -/** - * Lenient normalizer: when the input does not map to a supported - * BlueBubbles reaction type (iMessage tapback only supports - * love/like/dislike/laugh/emphasize/question), fall back to `love` - * so agents that react with a wider emoji vocabulary (e.g. 👀 to - * ack "seen, working on it") still produce a visible tapback instead - * of failing the whole reaction request. - * - * Contract errors (empty input) continue to bubble up so callers - * still catch misuse. - * - * Use this for model-facing paths. Callers that need to detect - * unsupported input should use {@link normalizeBlueBubblesReactionInputStrict}. - */ -export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string { - try { - return normalizeBlueBubblesReactionInputStrict(emoji, remove); - } catch (error) { - if (error instanceof Error && error.name === UNSUPPORTED_REACTION_ERROR) { - return remove ? "-love" : "love"; - } - throw error; - } -} - -export async function sendBlueBubblesReaction(params: { - chatGuid: string; - messageGuid: string; - emoji: string; - remove?: boolean; - partIndex?: number; - opts?: BlueBubblesReactionOpts; -}): Promise { - const chatGuid = params.chatGuid.trim(); - const messageGuid = params.messageGuid.trim(); - if (!chatGuid) { - throw new Error("BlueBubbles reaction requires chatGuid."); - } - if (!messageGuid) { - throw new Error("BlueBubbles reaction requires messageGuid."); - } - const reaction = normalizeBlueBubblesReactionInput(params.emoji, params.remove); - const client = createBlueBubblesClient(params.opts ?? {}); - if (getCachedBlueBubblesPrivateApiStatus(client.accountId) === false) { - throw new Error( - "BlueBubbles reaction requires Private API, but it is disabled on the BlueBubbles server.", - ); - } - // Go through the client's typed `react` method — it uses the same SSRF policy - // as every other client call, eliminating the asymmetric `{}` vs - // `{ allowedHostnames }` path that caused #59722. - const res = await client.react({ - chatGuid, - selectedMessageGuid: messageGuid, - reaction, - partIndex: typeof params.partIndex === "number" ? params.partIndex : 0, - timeoutMs: params.opts?.timeoutMs, - }); - if (!res.ok) { - const errorText = await res.text(); - throw new Error(`BlueBubbles reaction failed (${res.status}): ${errorText || "unknown"}`); - } -} diff --git a/extensions/bluebubbles/src/request-url.ts b/extensions/bluebubbles/src/request-url.ts deleted file mode 100644 index 1e91fd5b6397..000000000000 --- a/extensions/bluebubbles/src/request-url.ts +++ /dev/null @@ -1 +0,0 @@ -export { resolveRequestUrl } from "openclaw/plugin-sdk/request-url"; diff --git a/extensions/bluebubbles/src/runtime-api.ts b/extensions/bluebubbles/src/runtime-api.ts deleted file mode 100644 index 7fd3385ff213..000000000000 --- a/extensions/bluebubbles/src/runtime-api.ts +++ /dev/null @@ -1,61 +0,0 @@ -export { resolveAckReaction } from "openclaw/plugin-sdk/agent-runtime"; -export { - createActionGate, - jsonResult, - readNumberParam, - readReactionParams, - readStringParam, -} from "openclaw/plugin-sdk/channel-actions"; -export type { HistoryEntry } from "openclaw/plugin-sdk/reply-history"; -export { - evictOldHistoryKeys, - recordPendingHistoryEntryIfEnabled, -} from "openclaw/plugin-sdk/reply-history"; -export { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; -export { logAckFailure, logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; -export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound"; -export { BLUEBUBBLES_ACTION_NAMES, BLUEBUBBLES_ACTIONS } from "./actions-contract.js"; -export { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime"; -export { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status"; -export { collectBlueBubblesStatusIssues } from "./status-issues.js"; -export type { - BaseProbeResult, - ChannelAccountSnapshot, - ChannelMessageActionAdapter, - ChannelMessageActionName, -} from "openclaw/plugin-sdk/channel-contract"; -export type { - ChannelPlugin, - OpenClawConfig, - PluginRuntime, -} from "openclaw/plugin-sdk/channel-core"; -export { parseFiniteNumber } from "openclaw/plugin-sdk/number-runtime"; -export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; -export { - DM_GROUP_ACCESS_REASON, - readStoreAllowFromForDmPolicy, - resolveDmGroupAccessWithLists, -} from "openclaw/plugin-sdk/channel-policy"; -export { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; -export { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; -export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; -export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; -export { resolveRequestUrl } from "openclaw/plugin-sdk/request-url"; -export { buildProbeChannelStatusSummary } from "openclaw/plugin-sdk/channel-status"; -export { stripMarkdown } from "openclaw/plugin-sdk/text-runtime"; -export { extractToolSend } from "openclaw/plugin-sdk/tool-send"; -export { - WEBHOOK_RATE_LIMIT_DEFAULTS, - createFixedWindowRateLimiter, - createWebhookInFlightLimiter, - readWebhookBodyOrReject, - registerWebhookTargetWithPluginRoute, - resolveRequestClientIp, - resolveWebhookTargetWithAuthOrRejectSync, - withResolvedWebhookRequestPipeline, -} from "openclaw/plugin-sdk/webhook-ingress"; -export { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/context-visibility-runtime"; -export { - evaluateSupplementalContextVisibility, - shouldIncludeSupplementalContext, -} from "openclaw/plugin-sdk/security-runtime"; diff --git a/extensions/bluebubbles/src/runtime.ts b/extensions/bluebubbles/src/runtime.ts deleted file mode 100644 index f8b1098ec1a3..000000000000 --- a/extensions/bluebubbles/src/runtime.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; -import type { PluginRuntime } from "./runtime-api.js"; - -const runtimeStore = createPluginRuntimeStore({ - pluginId: "bluebubbles", - errorMessage: "BlueBubbles runtime not initialized", -}); -type LegacyRuntimeLogShape = { log?: (message: string) => void }; -export const setBlueBubblesRuntime = runtimeStore.setRuntime; - -export function clearBlueBubblesRuntime(): void { - runtimeStore.clearRuntime(); -} - -export function getBlueBubblesRuntime(): PluginRuntime { - return runtimeStore.getRuntime(); -} - -export function warnBlueBubbles(message: string): void { - const formatted = `[bluebubbles] ${message}`; - // Backward-compatible with tests/legacy injections that pass { log }. - const log = (runtimeStore.tryGetRuntime() as unknown as LegacyRuntimeLogShape | null)?.log; - if (typeof log === "function") { - log(formatted); - return; - } - console.warn(formatted); -} diff --git a/extensions/bluebubbles/src/secret-contract.ts b/extensions/bluebubbles/src/secret-contract.ts deleted file mode 100644 index c1d3ec26e4c6..000000000000 --- a/extensions/bluebubbles/src/secret-contract.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { - collectSimpleChannelFieldAssignments, - getChannelSurface, - type ResolverContext, - type SecretDefaults, -} from "openclaw/plugin-sdk/channel-secret-basic-runtime"; - -export const secretTargetRegistryEntries: import("openclaw/plugin-sdk/channel-secret-basic-runtime").SecretTargetRegistryEntry[] = - [ - { - id: "channels.bluebubbles.accounts.*.password", - targetType: "channels.bluebubbles.accounts.*.password", - configFile: "openclaw.json", - pathPattern: "channels.bluebubbles.accounts.*.password", - secretShape: "secret_input", - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.bluebubbles.password", - targetType: "channels.bluebubbles.password", - configFile: "openclaw.json", - pathPattern: "channels.bluebubbles.password", - secretShape: "secret_input", - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - ]; - -export function collectRuntimeConfigAssignments(params: { - config: { channels?: Record }; - defaults?: SecretDefaults; - context: ResolverContext; -}): void { - const resolved = getChannelSurface(params.config, "bluebubbles"); - if (!resolved) { - return; - } - const { channel: bluebubbles, surface } = resolved; - collectSimpleChannelFieldAssignments({ - channelKey: "bluebubbles", - field: "password", - channel: bluebubbles, - surface, - defaults: params.defaults, - context: params.context, - topInactiveReason: "no enabled account inherits this top-level BlueBubbles password.", - accountInactiveReason: "BlueBubbles account is disabled.", - }); -} - -export const channelSecrets = { - secretTargetRegistryEntries, - collectRuntimeConfigAssignments, -}; diff --git a/extensions/bluebubbles/src/secret-input.ts b/extensions/bluebubbles/src/secret-input.ts deleted file mode 100644 index f1b2aae5c923..000000000000 --- a/extensions/bluebubbles/src/secret-input.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - buildSecretInputSchema, - hasConfiguredSecretInput, - normalizeResolvedSecretInputString, - normalizeSecretInputString, -} from "openclaw/plugin-sdk/secret-input"; diff --git a/extensions/bluebubbles/src/send-helpers.ts b/extensions/bluebubbles/src/send-helpers.ts deleted file mode 100644 index 0460328be4bc..000000000000 --- a/extensions/bluebubbles/src/send-helpers.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { asRecord } from "./monitor-normalize.js"; -import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js"; -import type { BlueBubblesSendTarget } from "./types.js"; - -export function resolveBlueBubblesSendTarget(raw: string): BlueBubblesSendTarget { - const parsed = parseBlueBubblesTarget(raw); - if (parsed.kind === "handle") { - return { - kind: "handle", - address: normalizeBlueBubblesHandle(parsed.to), - service: parsed.service, - }; - } - if (parsed.kind === "chat_id") { - return { kind: "chat_id", chatId: parsed.chatId }; - } - if (parsed.kind === "chat_guid") { - return { kind: "chat_guid", chatGuid: parsed.chatGuid }; - } - return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier }; -} - -export function extractBlueBubblesMessageId(payload: unknown): string { - if (!payload || typeof payload !== "object") { - return "unknown"; - } - - const record = payload as Record; - const dataRecord = asRecord(record.data); - const resultRecord = asRecord(record.result); - const payloadRecord = asRecord(record.payload); - const messageRecord = asRecord(record.message); - const dataArrayFirst = Array.isArray(record.data) ? asRecord(record.data[0]) : null; - - const roots = [record, dataRecord, resultRecord, payloadRecord, messageRecord, dataArrayFirst]; - - for (const root of roots) { - if (!root) { - continue; - } - const candidates = [ - root.message_id, - root.messageId, - root.messageGuid, - root.message_guid, - root.guid, - root.id, - root.uuid, - ]; - for (const candidate of candidates) { - if (typeof candidate === "string" && candidate.trim()) { - return candidate.trim(); - } - if (typeof candidate === "number" && Number.isFinite(candidate)) { - return String(candidate); - } - } - } - - return "unknown"; -} diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts deleted file mode 100644 index ea2e23747858..000000000000 --- a/extensions/bluebubbles/src/send.test.ts +++ /dev/null @@ -1,1648 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import "./test-mocks.js"; -import { - fetchBlueBubblesServerInfo, - getCachedBlueBubblesPrivateApiStatus, - isMacOS26OrHigher, -} from "./probe.js"; -import type { PluginRuntime } from "./runtime-api.js"; -import { clearBlueBubblesRuntime, setBlueBubblesRuntime } from "./runtime.js"; -import { sendMessageBlueBubbles, resolveChatGuidForTarget, createChatForHandle } from "./send.js"; -import { - BLUE_BUBBLES_PRIVATE_API_STATUS, - createBlueBubblesFetchGuardPassthroughInstaller, - installBlueBubblesFetchTestHooks, - mockBlueBubblesPrivateApiStatusOnce, -} from "./test-harness.js"; -import { _setFetchGuardForTesting, type BlueBubblesSendTarget } from "./types.js"; - -const mockFetch = vi.fn(); -const privateApiStatusMock = vi.mocked(getCachedBlueBubblesPrivateApiStatus); -const fetchServerInfoMock = vi.mocked(fetchBlueBubblesServerInfo); -const isMacOS26OrHigherMock = vi.mocked(isMacOS26OrHigher); -const setFetchGuardPassthrough = createBlueBubblesFetchGuardPassthroughInstaller(); - -installBlueBubblesFetchTestHooks({ - mockFetch, - privateApiStatusMock, -}); - -function mockResolvedHandleTarget( - guid: string = "iMessage;-;+15551234567", - address: string = "+15551234567", -) { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid, - participants: [{ address }], - }, - ], - }), - }); -} - -function mockSendResponse(body: unknown) { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(JSON.stringify(body)), - }); -} - -function mockNewChatSendResponse(guid: string) { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - data: { guid }, - }), - ), - }); -} - -function installSsrFPolicyCapture(policies: unknown[]) { - setFetchGuardPassthrough((policy) => { - policies.push(policy); - }); -} - -describe("send", () => { - describe("resolveChatGuidForTarget", () => { - const resolveHandleTargetGuid = async ( - data: Array>, - service: "imessage" | "sms" | "auto" = "imessage", - ) => { - // First page returns the provided chats; second page is empty so the - // pagination loop exits cleanly. We can't break early on participant or - // non-preferred direct matches — a stronger preferred-service direct - // match could still appear on a later page — so we always need to mock - // at least one trailing empty page. - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }); - - const target: BlueBubblesSendTarget = { - kind: "handle", - address: "+15551234567", - service, - }; - return await resolveChatGuidForTarget({ - baseUrl: "http://localhost:1234", - password: "test", - target, - }); - }; - - it("returns chatGuid directly for chat_guid target", async () => { - const target: BlueBubblesSendTarget = { - kind: "chat_guid", - chatGuid: "iMessage;-;+15551234567", - }; - const result = await resolveChatGuidForTarget({ - baseUrl: "http://localhost:1234", - password: "test", - target, - }); - expect(result).toBe("iMessage;-;+15551234567"); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it("queries chats to resolve chat_id target", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { id: 123, guid: "iMessage;-;chat123", participants: [] }, - { id: 456, guid: "iMessage;-;chat456", participants: [] }, - ], - }), - }); - - const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 456 }; - const result = await resolveChatGuidForTarget({ - baseUrl: "http://localhost:1234", - password: "test", - target, - }); - - expect(result).toBe("iMessage;-;chat456"); - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining("/api/v1/chat/query"), - expect.objectContaining({ method: "POST" }), - ); - }); - - it("queries chats to resolve chat_identifier target", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - identifier: "chat123@group.imessage", - guid: "iMessage;-;chat123", - participants: [], - }, - ], - }), - }); - - const target: BlueBubblesSendTarget = { - kind: "chat_identifier", - chatIdentifier: "chat123@group.imessage", - }; - const result = await resolveChatGuidForTarget({ - baseUrl: "http://localhost:1234", - password: "test", - target, - }); - - expect(result).toBe("iMessage;-;chat123"); - }); - - it("matches chat_identifier against the 3rd component of chat GUID", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "iMessage;+;chat660250192681427962", - participants: [], - }, - ], - }), - }); - - const target: BlueBubblesSendTarget = { - kind: "chat_identifier", - chatIdentifier: "chat660250192681427962", - }; - const result = await resolveChatGuidForTarget({ - baseUrl: "http://localhost:1234", - password: "test", - target, - }); - - expect(result).toBe("iMessage;+;chat660250192681427962"); - }); - - it("resolves handle target by matching participant", async () => { - const result = await resolveHandleTargetGuid([ - { - guid: "iMessage;-;+15559999999", - participants: [{ address: "+15559999999" }], - }, - { - guid: "iMessage;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - ]); - - expect(result).toBe("iMessage;-;+15551234567"); - }); - - it("prefers direct chat guid when handle also appears in a group chat", async () => { - const result = await resolveHandleTargetGuid([ - { - guid: "iMessage;+;group-123", - participants: [{ address: "+15551234567" }, { address: "+15550001111" }], - }, - { - guid: "iMessage;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - ]); - - expect(result).toBe("iMessage;-;+15551234567"); - }); - - it("prefers iMessage over SMS when both chats exist for the same handle", async () => { - // Both chats exist; we should never silently downgrade to SMS. - const result = await resolveHandleTargetGuid([ - { - guid: "SMS;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - { - guid: "iMessage;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - ]); - - expect(result).toBe("iMessage;-;+15551234567"); - }); - - it("prefers iMessage over SMS even when SMS appears first", async () => { - const result = await resolveHandleTargetGuid([ - { - guid: "SMS;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - { - guid: "iMessage;-;+15559999999", - participants: [{ address: "+15559999999" }], - }, - { - guid: "iMessage;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - ]); - - expect(result).toBe("iMessage;-;+15551234567"); - }); - - it("falls back to SMS when no iMessage chat exists for the handle", async () => { - // First page: SMS-only DM. Second page: empty (stops pagination). - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "SMS;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - ], - }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }); - - const target: BlueBubblesSendTarget = { - kind: "handle", - address: "+15551234567", - service: "imessage", - }; - const result = await resolveChatGuidForTarget({ - baseUrl: "http://localhost:1234", - password: "test", - target, - }); - - expect(result).toBe("SMS;-;+15551234567"); - }); - - it("respects explicit service: 'sms' and prefers SMS direct match over iMessage", async () => { - // Regression: when caller passes `sms:+15551234567` (target.service === - // 'sms'), explicit SMS intent must beat the default iMessage preference. - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "iMessage;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - { - guid: "SMS;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - ], - }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }); - - const target: BlueBubblesSendTarget = { - kind: "handle", - address: "+15551234567", - service: "sms", - }; - const result = await resolveChatGuidForTarget({ - baseUrl: "http://localhost:1234", - password: "test", - target, - }); - - expect(result).toBe("SMS;-;+15551234567"); - }); - - it("falls back to iMessage when service: 'sms' is requested but no SMS chat exists", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "iMessage;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - ], - }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }); - - const target: BlueBubblesSendTarget = { - kind: "handle", - address: "+15551234567", - service: "sms", - }; - const result = await resolveChatGuidForTarget({ - baseUrl: "http://localhost:1234", - password: "test", - target, - }); - - expect(result).toBe("iMessage;-;+15551234567"); - }); - - it("prefers a later-page direct iMessage match over an earlier participant iMessage match", async () => { - // Regression: a participant-based iMessage match must NOT short-circuit - // pagination and beat a direct `iMessage;-;` match on a later page. - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "iMessage;-;alt-handle", - participants: [{ address: "+15551234567" }], - }, - ], - }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "iMessage;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - ], - }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }); - - const target: BlueBubblesSendTarget = { - kind: "handle", - address: "+15551234567", - service: "imessage", - }; - const result = await resolveChatGuidForTarget({ - baseUrl: "http://localhost:1234", - password: "test", - target, - }); - - expect(result).toBe("iMessage;-;+15551234567"); - }); - - it("prefers a later-page iMessage participant match over an earlier unknown-service direct match", async () => { - // Regression: an unknown-service direct match on page 1 must NOT short-circuit - // pagination and beat a real iMessage participant match on page 2. - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "WeirdService;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - ], - }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "iMessage;-;alt-handle", - participants: [{ address: "+15551234567" }], - }, - ], - }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }); - - const target: BlueBubblesSendTarget = { - kind: "handle", - address: "+15551234567", - service: "imessage", - }; - const result = await resolveChatGuidForTarget({ - baseUrl: "http://localhost:1234", - password: "test", - target, - }); - - expect(result).toBe("iMessage;-;alt-handle"); - }); - - it("prefers iMessage over SMS via participant match", async () => { - const result = await resolveHandleTargetGuid([ - { - guid: "SMS;-;alt-handle", - participants: [{ address: "+15551234567" }], - }, - { - guid: "iMessage;-;alt-handle", - participants: [{ address: "+15551234567" }], - }, - ]); - - expect(result).toBe("iMessage;-;alt-handle"); - }); - - it("returns null when handle only exists in group chat (not DM)", async () => { - // This is the critical fix: if a phone number only exists as a participant in a group chat - // (no direct DM chat), we should NOT send to that group. Return null instead. - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "iMessage;+;group-the-council", - participants: [ - { address: "+12622102921" }, - { address: "+15550001111" }, - { address: "+15550002222" }, - ], - }, - ], - }), - }) - // Empty second page to stop pagination - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }); - - const target: BlueBubblesSendTarget = { - kind: "handle", - address: "+12622102921", - service: "imessage", - }; - const result = await resolveChatGuidForTarget({ - baseUrl: "http://localhost:1234", - password: "test", - target, - }); - - // Should return null, NOT the group chat GUID - expect(result).toBeNull(); - }); - - it("returns null when chat not found", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }); - - const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 999 }; - const result = await resolveChatGuidForTarget({ - baseUrl: "http://localhost:1234", - password: "test", - target, - }); - - expect(result).toBeNull(); - }); - - it("handles API error gracefully", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - }); - - const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 123 }; - const result = await resolveChatGuidForTarget({ - baseUrl: "http://localhost:1234", - password: "test", - target, - }); - - expect(result).toBeNull(); - }); - - it("paginates through chats to find match", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: Array(500) - .fill(null) - .map((_, i) => ({ - id: i, - guid: `chat-${i}`, - participants: [], - })), - }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [{ id: 555, guid: "found-chat", participants: [] }], - }), - }); - - const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 555 }; - const result = await resolveChatGuidForTarget({ - baseUrl: "http://localhost:1234", - password: "test", - target, - }); - - expect(result).toBe("found-chat"); - expect(mockFetch).toHaveBeenCalledTimes(2); - }); - - it("normalizes handle addresses for matching", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "iMessage;-;test@example.com", - participants: [{ address: "Test@Example.COM" }], - }, - ], - }), - }); - - const target: BlueBubblesSendTarget = { - kind: "handle", - address: "test@example.com", - service: "auto", - }; - const result = await resolveChatGuidForTarget({ - baseUrl: "http://localhost:1234", - password: "test", - target, - }); - - expect(result).toBe("iMessage;-;test@example.com"); - }); - - it("extracts guid from various response formats", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - chatGuid: "format1-guid", - id: 100, - participants: [], - }, - ], - }), - }); - - const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 100 }; - const result = await resolveChatGuidForTarget({ - baseUrl: "http://localhost:1234", - password: "test", - target, - }); - - expect(result).toBe("format1-guid"); - }); - }); - - describe("sendMessageBlueBubbles", () => { - beforeEach(() => { - mockFetch.mockReset(); - fetchServerInfoMock.mockReset(); - fetchServerInfoMock.mockResolvedValue(null); - }); - - it("throws when text is empty", async () => { - await expect( - sendMessageBlueBubbles("+15551234567", "", { - serverUrl: "http://localhost:1234", - password: "test", - }), - ).rejects.toThrow("requires text"); - }); - - it("throws when text is whitespace only", async () => { - await expect( - sendMessageBlueBubbles("+15551234567", " ", { - serverUrl: "http://localhost:1234", - password: "test", - }), - ).rejects.toThrow("requires text"); - }); - - it("throws when text becomes empty after markdown stripping", async () => { - // Edge case: input like "***" or "---" passes initial check but becomes empty after stripMarkdown - await expect( - sendMessageBlueBubbles("+15551234567", "***", { - serverUrl: "http://localhost:1234", - password: "test", - }), - ).rejects.toThrow("empty after markdown removal"); - }); - - it("throws when serverUrl is missing", async () => { - await expect(sendMessageBlueBubbles("+15551234567", "Hello", {})).rejects.toThrow( - "serverUrl is required", - ); - }); - - it("throws when password is missing", async () => { - await expect( - sendMessageBlueBubbles("+15551234567", "Hello", { - serverUrl: "http://localhost:1234", - }), - ).rejects.toThrow("password is required"); - }); - - it("throws when chatGuid cannot be resolved for non-handle targets", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }); - - await expect( - sendMessageBlueBubbles("chat_id:999", "Hello", { - serverUrl: "http://localhost:1234", - password: "test", - }), - ).rejects.toThrow("chatGuid not found"); - }); - - it("sends message successfully", async () => { - mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg-uuid-123" } }); - - const result = await sendMessageBlueBubbles("+15551234567", "Hello world!", { - serverUrl: "http://localhost:1234", - password: "test", - }); - - expect(result.messageId).toBe("msg-uuid-123"); - expect(result.receipt).toMatchObject({ - primaryPlatformMessageId: "msg-uuid-123", - platformMessageIds: ["msg-uuid-123"], - parts: [ - { - platformMessageId: "msg-uuid-123", - kind: "text", - raw: { - channel: "bluebubbles", - conversationId: "iMessage;-;+15551234567", - messageId: "msg-uuid-123", - }, - }, - ], - }); - expect(mockFetch).toHaveBeenCalledTimes(2); - - const sendCall = mockFetch.mock.calls[1]; - expect(sendCall[0]).toContain("/api/v1/message/text"); - const body = JSON.parse(sendCall[1].body); - expect(body.chatGuid).toBe("iMessage;-;+15551234567"); - expect(body.message).toBe("Hello world!"); - expect(body.method).toBe("apple-script"); - }); - - it("auto-enables private-network fetches for loopback serverUrl when allowPrivateNetwork is not set", async () => { - const policies: unknown[] = []; - installSsrFPolicyCapture(policies); - mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg-loopback" } }); - - try { - const result = await sendMessageBlueBubbles("+15551234567", "Hello world!", { - serverUrl: "http://localhost:1234", - password: "test", - }); - - expect(result.messageId).toBe("msg-loopback"); - expect(policies).toEqual([{ allowPrivateNetwork: true }, { allowPrivateNetwork: true }]); - } finally { - _setFetchGuardForTesting(null); - } - }); - - it("auto-enables private-network fetches for private IP serverUrl when allowPrivateNetwork is not set", async () => { - const policies: unknown[] = []; - installSsrFPolicyCapture(policies); - mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg-private-ip" } }); - - try { - const result = await sendMessageBlueBubbles("+15551234567", "Hello world!", { - serverUrl: "http://192.168.1.5:1234", - password: "test", - }); - - expect(result.messageId).toBe("msg-private-ip"); - expect(policies).toEqual([{ allowPrivateNetwork: true }, { allowPrivateNetwork: true }]); - } finally { - _setFetchGuardForTesting(null); - } - }); - - it("strips markdown formatting from outbound messages", async () => { - mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg-uuid-stripped" } }); - - const result = await sendMessageBlueBubbles( - "+15551234567", - "**Bold** and *italic* with `code`\n## Header", - { - serverUrl: "http://localhost:1234", - password: "test", - }, - ); - - expect(result.messageId).toBe("msg-uuid-stripped"); - - const sendCall = mockFetch.mock.calls[1]; - const body = JSON.parse(sendCall[1].body); - // Markdown should be stripped: no asterisks, backticks, or hashes - expect(body.message).toBe("Bold and italic with code\nHeader"); - }); - - it("strips markdown when creating a new chat", async () => { - mockNewChatSendResponse("new-msg-stripped"); - - const result = await sendMessageBlueBubbles("+15550009999", "**Welcome** to the _chat_!", { - serverUrl: "http://localhost:1234", - password: "test", - }); - - expect(result.messageId).toBe("new-msg-stripped"); - - const createCall = mockFetch.mock.calls[1]; - expect(createCall[0]).toContain("/api/v1/chat/new"); - const body = JSON.parse(createCall[1].body); - // Markdown should be stripped - expect(body.message).toBe("Welcome to the chat!"); - }); - - it("creates a new chat when handle target is missing", async () => { - mockNewChatSendResponse("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(result.receipt).toMatchObject({ - primaryPlatformMessageId: "new-msg-guid", - platformMessageIds: ["new-msg-guid"], - parts: [ - { - platformMessageId: "new-msg-guid", - kind: "text", - }, - ], - }); - 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 () => { - mockBlueBubblesPrivateApiStatusOnce( - privateApiStatusMock, - BLUE_BUBBLES_PRIVATE_API_STATUS.enabled, - ); - mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg-uuid-124" } }); - - const result = await sendMessageBlueBubbles("+15551234567", "Replying", { - serverUrl: "http://localhost:1234", - password: "test", - replyToMessageGuid: "reply-guid-123", - replyToPartIndex: 1, - }); - - expect(result.messageId).toBe("msg-uuid-124"); - expect(result.receipt).toMatchObject({ - primaryPlatformMessageId: "msg-uuid-124", - platformMessageIds: ["msg-uuid-124"], - replyToId: "reply-guid-123", - parts: [ - { - platformMessageId: "msg-uuid-124", - kind: "text", - replyToId: "reply-guid-123", - }, - ], - }); - expect(mockFetch).toHaveBeenCalledTimes(2); - - const sendCall = mockFetch.mock.calls[1]; - const body = JSON.parse(sendCall[1].body); - expect(body.method).toBe("private-api"); - expect(body.selectedMessageGuid).toBe("reply-guid-123"); - expect(body.partIndex).toBe(1); - }); - - it("downgrades threaded reply to plain send when private API is disabled", async () => { - mockBlueBubblesPrivateApiStatusOnce( - privateApiStatusMock, - BLUE_BUBBLES_PRIVATE_API_STATUS.disabled, - ); - mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg-uuid-plain" } }); - - const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", { - serverUrl: "http://localhost:1234", - password: "test", - replyToMessageGuid: "reply-guid-123", - replyToPartIndex: 1, - }); - - expect(result.messageId).toBe("msg-uuid-plain"); - const sendCall = mockFetch.mock.calls[1]; - const body = JSON.parse(sendCall[1].body); - expect(body.method).toBe("apple-script"); - expect(body.selectedMessageGuid).toBeUndefined(); - expect(body.partIndex).toBeUndefined(); - }); - - it("normalizes effect names and uses private-api for effects", async () => { - mockBlueBubblesPrivateApiStatusOnce( - privateApiStatusMock, - BLUE_BUBBLES_PRIVATE_API_STATUS.enabled, - ); - mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg-uuid-125" } }); - - const result = await sendMessageBlueBubbles("+15551234567", "Hello", { - serverUrl: "http://localhost:1234", - password: "test", - effectId: "invisible ink", - }); - - expect(result.messageId).toBe("msg-uuid-125"); - expect(mockFetch).toHaveBeenCalledTimes(2); - - const sendCall = mockFetch.mock.calls[1]; - const body = JSON.parse(sendCall[1].body); - expect(body.method).toBe("private-api"); - expect(body.effectId).toBe("com.apple.MobileSMS.expressivesend.invisibleink"); - }); - - // macOS 26 Tahoe broke AppleScript Messages.app automation (-1700). When - // Private API is available on these hosts, plain text sends should prefer - // Private API even without reply/effect features. (#53159 Bug B, #64480) - it("forces Private API for plain text on macOS 26 when available", async () => { - mockBlueBubblesPrivateApiStatusOnce( - privateApiStatusMock, - BLUE_BUBBLES_PRIVATE_API_STATUS.enabled, - ); - isMacOS26OrHigherMock.mockReturnValue(true); - mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg-macos26" } }); - - try { - const result = await sendMessageBlueBubbles("+15551234567", "Plain text", { - serverUrl: "http://localhost:1234", - password: "test", - }); - - expect(result.messageId).toBe("msg-macos26"); - const sendCall = mockFetch.mock.calls[1]; - const body = JSON.parse(sendCall[1].body); - expect(body.method).toBe("private-api"); - } finally { - isMacOS26OrHigherMock.mockReturnValue(false); - } - }); - - // If macOS 26 host has Private API disabled, there is nothing we can do — - // the AppleScript path is broken on that OS. We still tag the send - // explicitly as apple-script rather than omitting `method`; BB Server's - // behavior on an omitted field is version-dependent and silently drops - // on some setups, which is the worse failure mode. (#64480) - it("falls back to apple-script on macOS 26 when Private API is disabled", async () => { - mockBlueBubblesPrivateApiStatusOnce( - privateApiStatusMock, - BLUE_BUBBLES_PRIVATE_API_STATUS.disabled, - ); - isMacOS26OrHigherMock.mockReturnValue(true); - mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg-macos26-no-pa" } }); - - try { - const result = await sendMessageBlueBubbles("+15551234567", "Plain text", { - serverUrl: "http://localhost:1234", - password: "test", - }); - - expect(result.messageId).toBe("msg-macos26-no-pa"); - const sendCall = mockFetch.mock.calls[1]; - const body = JSON.parse(sendCall[1].body); - expect(body.method).toBe("apple-script"); - } finally { - isMacOS26OrHigherMock.mockReturnValue(false); - } - }); - - it("warns and downgrades private-api features when status is unknown", async () => { - const runtimeLog = vi.fn(); - setBlueBubblesRuntime({ log: runtimeLog } as unknown as PluginRuntime); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg-uuid-unknown" } }); - - try { - const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", { - serverUrl: "http://localhost:1234", - password: "test", - replyToMessageGuid: "reply-guid-123", - effectId: "invisible ink", - }); - - expect(result.messageId).toBe("msg-uuid-unknown"); - expect(runtimeLog).toHaveBeenCalledTimes(1); - expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown"); - expect(warnSpy).not.toHaveBeenCalled(); - - const sendCall = mockFetch.mock.calls[1]; - const body = JSON.parse(sendCall[1].body); - expect(body.method).toBe("apple-script"); - expect(body.selectedMessageGuid).toBeUndefined(); - expect(body.partIndex).toBeUndefined(); - expect(body.effectId).toBeUndefined(); - } finally { - clearBlueBubblesRuntime(); - warnSpy.mockRestore(); - } - }); - - it("sends message with chat_guid target directly", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - data: { messageId: "direct-msg-123" }, - }), - ), - }); - - const result = await sendMessageBlueBubbles( - "chat_guid:iMessage;-;direct-chat", - "Direct message", - { - serverUrl: "http://localhost:1234", - password: "test", - }, - ); - - expect(result.messageId).toBe("direct-msg-123"); - expect(mockFetch).toHaveBeenCalledTimes(1); - }); - - it("handles send failure", async () => { - mockResolvedHandleTarget(); - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - text: () => Promise.resolve("Internal server error"), - }); - - await expect( - sendMessageBlueBubbles("+15551234567", "Hello", { - serverUrl: "http://localhost:1234", - password: "test", - }), - ).rejects.toThrow("send failed (500)"); - }); - - it("handles empty response body", async () => { - mockResolvedHandleTarget(); - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - const result = await sendMessageBlueBubbles("+15551234567", "Hello", { - serverUrl: "http://localhost:1234", - password: "test", - }); - - expect(result.messageId).toBe("ok"); - expect(result.receipt.platformMessageIds).toEqual([]); - expect(result.receipt.parts).toEqual([]); - }); - - it("handles invalid JSON response body", async () => { - mockResolvedHandleTarget(); - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve("not valid json"), - }); - - const result = await sendMessageBlueBubbles("+15551234567", "Hello", { - serverUrl: "http://localhost:1234", - password: "test", - }); - - expect(result.messageId).toBe("ok"); - expect(result.receipt.platformMessageIds).toEqual([]); - expect(result.receipt.parts).toEqual([]); - }); - - it("extracts messageId from various response formats", async () => { - mockResolvedHandleTarget(); - mockSendResponse({ id: "numeric-id-456" }); - - const result = await sendMessageBlueBubbles("+15551234567", "Hello", { - serverUrl: "http://localhost:1234", - password: "test", - }); - - expect(result.messageId).toBe("numeric-id-456"); - }); - - it("extracts messageGuid from response payload", async () => { - mockResolvedHandleTarget(); - mockSendResponse({ data: { messageGuid: "msg-guid-789" } }); - - const result = await sendMessageBlueBubbles("+15551234567", "Hello", { - serverUrl: "http://localhost:1234", - password: "test", - }); - - expect(result.messageId).toBe("msg-guid-789"); - }); - - it("extracts top-level message_id from response payload", async () => { - mockResolvedHandleTarget(); - mockSendResponse({ message_id: "bb-msg-321" }); - - const result = await sendMessageBlueBubbles("+15551234567", "Hello", { - serverUrl: "http://localhost:1234", - password: "test", - }); - - expect(result.messageId).toBe("bb-msg-321"); - }); - - it("extracts nested result.message_id from response payload", async () => { - mockResolvedHandleTarget(); - mockSendResponse({ result: { message_id: "bb-msg-654" } }); - - const result = await sendMessageBlueBubbles("+15551234567", "Hello", { - serverUrl: "http://localhost:1234", - password: "test", - }); - - expect(result.messageId).toBe("bb-msg-654"); - }); - - it("resolves credentials from config", async () => { - mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg-123" } }); - - const result = await sendMessageBlueBubbles("+15551234567", "Hello", { - cfg: { - channels: { - bluebubbles: { - serverUrl: "http://config-server:5678", - password: "config-pass", - }, - }, - }, - }); - - expect(result.messageId).toBe("msg-123"); - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("config-server:5678"); - }); - - it("includes tempGuid in request payload", async () => { - mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg" } }); - - await sendMessageBlueBubbles("+15551234567", "Hello", { - serverUrl: "http://localhost:1234", - password: "test", - }); - - const sendCall = mockFetch.mock.calls[1]; - const body = JSON.parse(sendCall[1].body); - expect(body.tempGuid).toBeDefined(); - expect(typeof body.tempGuid).toBe("string"); - expect(body.tempGuid.length).toBeGreaterThan(0); - }); - - describe("lazy private API refresh (#43764)", () => { - it("does not refresh when cache is populated (cache hit)", async () => { - mockBlueBubblesPrivateApiStatusOnce( - privateApiStatusMock, - BLUE_BUBBLES_PRIVATE_API_STATUS.enabled, - ); - mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg-cached" } }); - - const result = await sendMessageBlueBubbles("+15551234567", "Replying", { - serverUrl: "http://localhost:1234", - password: "test", - replyToMessageGuid: "reply-guid-123", - }); - - expect(result.messageId).toBe("msg-cached"); - expect(fetchServerInfoMock).not.toHaveBeenCalled(); - const sendCall = mockFetch.mock.calls[1]; - const body = JSON.parse(sendCall[1].body); - expect(body.method).toBe("private-api"); - expect(body.selectedMessageGuid).toBe("reply-guid-123"); - }); - - it("refreshes cache when expired and reply threading is requested", async () => { - // First call returns null (cache expired), after refresh returns enabled - privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(true); - fetchServerInfoMock.mockResolvedValueOnce({ private_api: true }); - mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg-refreshed" } }); - - const result = await sendMessageBlueBubbles("+15551234567", "Replying", { - serverUrl: "http://localhost:1234", - password: "test", - replyToMessageGuid: "reply-guid-456", - }); - - expect(result.messageId).toBe("msg-refreshed"); - expect(fetchServerInfoMock).toHaveBeenCalledTimes(1); - expect(fetchServerInfoMock).toHaveBeenCalledWith( - expect.objectContaining({ - baseUrl: expect.stringContaining("localhost"), - password: "test", - accountId: expect.any(String), - allowPrivateNetwork: expect.any(Boolean), - }), - ); - const sendCall = mockFetch.mock.calls[1]; - const body = JSON.parse(sendCall[1].body); - expect(body.method).toBe("private-api"); - expect(body.selectedMessageGuid).toBe("reply-guid-456"); - }); - - it("refreshes cache when expired and effect is requested", async () => { - privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(true); - fetchServerInfoMock.mockResolvedValueOnce({ private_api: true }); - mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg-effect-refreshed" } }); - - const result = await sendMessageBlueBubbles("+15551234567", "Party!", { - serverUrl: "http://localhost:1234", - password: "test", - effectId: "confetti", - }); - - expect(result.messageId).toBe("msg-effect-refreshed"); - expect(fetchServerInfoMock).toHaveBeenCalledTimes(1); - const sendCall = mockFetch.mock.calls[1]; - const body = JSON.parse(sendCall[1].body); - expect(body.method).toBe("private-api"); - expect(body.effectId).toBe("com.apple.messages.effect.CKConfettiEffect"); - }); - - it("degrades gracefully when refresh fails", async () => { - // Cache expired, refresh throws — should fall back to existing behavior - fetchServerInfoMock.mockRejectedValueOnce(new Error("network error")); - mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg-degraded" } }); - - const runtimeLog = vi.fn(); - setBlueBubblesRuntime({ log: runtimeLog } as unknown as PluginRuntime); - - try { - const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", { - serverUrl: "http://localhost:1234", - password: "test", - replyToMessageGuid: "reply-guid-789", - }); - - expect(result.messageId).toBe("msg-degraded"); - expect(fetchServerInfoMock).toHaveBeenCalledTimes(1); - // Should warn about unknown status and send without threading - expect(runtimeLog).toHaveBeenCalledTimes(1); - expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown"); - const sendCall = mockFetch.mock.calls[1]; - const body = JSON.parse(sendCall[1].body); - expect(body.method).toBe("apple-script"); - expect(body.selectedMessageGuid).toBeUndefined(); - } finally { - clearBlueBubblesRuntime(); - } - }); - - it("throws for effects when refresh succeeds with private_api: false", async () => { - // Cache expired, refresh succeeds but Private API is explicitly disabled - privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(false); - fetchServerInfoMock.mockResolvedValueOnce({ private_api: false }); - mockResolvedHandleTarget(); - - await expect( - sendMessageBlueBubbles("+15551234567", "Party!", { - serverUrl: "http://localhost:1234", - password: "test", - effectId: "confetti", - }), - ).rejects.toThrow("Private API"); - - expect(fetchServerInfoMock).toHaveBeenCalledTimes(1); - }); - - it("degrades reply threading when refresh succeeds with private_api: false", async () => { - // Cache expired, refresh succeeds but Private API is explicitly disabled - // Should degrade without the "unknown" warning (status is known: disabled) - privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(false); - fetchServerInfoMock.mockResolvedValueOnce({ private_api: false }); - mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg-disabled-after-refresh" } }); - - const runtimeLog = vi.fn(); - setBlueBubblesRuntime({ log: runtimeLog } as unknown as PluginRuntime); - - try { - const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", { - serverUrl: "http://localhost:1234", - password: "test", - replyToMessageGuid: "reply-guid-disabled", - }); - - expect(result.messageId).toBe("msg-disabled-after-refresh"); - expect(fetchServerInfoMock).toHaveBeenCalledTimes(1); - // No warning — status is known (disabled), not unknown - expect(runtimeLog).not.toHaveBeenCalled(); - const sendCall = mockFetch.mock.calls[1]; - const body = JSON.parse(sendCall[1].body); - expect(body.method).toBe("apple-script"); - expect(body.selectedMessageGuid).toBeUndefined(); - } finally { - clearBlueBubblesRuntime(); - } - }); - - // Plain-text sends also need the cache populated so `isMacOS26OrHigher` - // can read `os_version` from the same `serverInfoCache`. Without a - // refresh on cold/expired cache, macOS 26 detection would silently - // miss and force-route would fall back to broken AppleScript. - // (Greptile/Codex PR #69070) - it("refreshes cache for plain-text sends when status is unknown", async () => { - // First call returns null (cache cold/expired). The refresh path - // fetches server info; plain-text send still uses AppleScript when - // Private API is disabled on the server — but the refresh ran. - privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(false); - fetchServerInfoMock.mockResolvedValueOnce({ private_api: false }); - mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg-plain-refreshed" } }); - - const result = await sendMessageBlueBubbles("+15551234567", "Plain message", { - serverUrl: "http://localhost:1234", - password: "test", - }); - - expect(result.messageId).toBe("msg-plain-refreshed"); - expect(fetchServerInfoMock).toHaveBeenCalledTimes(1); - const sendCall = mockFetch.mock.calls[1]; - const body = JSON.parse(sendCall[1].body); - expect(body.method).toBe("apple-script"); - }); - - // Cold cache + macOS 26 + Private API enabled on refresh — the - // refresh populates the cache, `isMacOS26OrHigher` returns true, and - // plain-text routes through Private API instead of broken AppleScript. - // (Greptile/Codex PR #69070) - it("force-routes macOS 26 plain-text through Private API after cold-cache refresh", async () => { - privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(true); - fetchServerInfoMock.mockResolvedValueOnce({ - private_api: true, - os_version: "26.0", - }); - isMacOS26OrHigherMock.mockReturnValue(true); - mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg-macos26-refreshed" } }); - - try { - const result = await sendMessageBlueBubbles("+15551234567", "Plain message", { - serverUrl: "http://localhost:1234", - password: "test", - }); - - expect(result.messageId).toBe("msg-macos26-refreshed"); - expect(fetchServerInfoMock).toHaveBeenCalledTimes(1); - const sendCall = mockFetch.mock.calls[1]; - const body = JSON.parse(sendCall[1].body); - expect(body.method).toBe("private-api"); - } finally { - isMacOS26OrHigherMock.mockReturnValue(false); - } - }); - - it("degrades gracefully when refresh returns null (server unreachable)", async () => { - // Cache expired, refresh returns null (server info unavailable) - fetchServerInfoMock.mockResolvedValueOnce(null); - mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg-null-refresh" } }); - - const runtimeLog = vi.fn(); - setBlueBubblesRuntime({ log: runtimeLog } as unknown as PluginRuntime); - - try { - const result = await sendMessageBlueBubbles("+15551234567", "Reply attempt", { - serverUrl: "http://localhost:1234", - password: "test", - replyToMessageGuid: "reply-guid-000", - }); - - expect(result.messageId).toBe("msg-null-refresh"); - expect(fetchServerInfoMock).toHaveBeenCalledTimes(1); - // privateApiStatus still null after failed refresh → warning + degradation - expect(runtimeLog).toHaveBeenCalledTimes(1); - expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown"); - } finally { - clearBlueBubblesRuntime(); - } - }); - }); - - describe("send timeout (#67486)", () => { - // Capture the `timeoutMs` that the SSRF guard receives on each call. - // Index 0 is the `chat/query` preflight; index 1 is the actual - // `/api/v1/message/text` POST — that's the one we care about. - function installTimeoutCapture(): (number | undefined)[] { - const timeouts: (number | undefined)[] = []; - _setFetchGuardForTesting(async (guardParams) => { - timeouts.push(guardParams.timeoutMs); - const raw = await globalThis.fetch(guardParams.url, guardParams.init); - // Mirrors `createBlueBubblesFetchGuardPassthroughInstaller` so both - // `.json()`-only chat-query mocks and `.text()`-only send mocks work. - let body: ArrayBuffer; - if ( - typeof (raw as { arrayBuffer?: () => Promise }).arrayBuffer === "function" - ) { - body = await (raw as { arrayBuffer: () => Promise }).arrayBuffer(); - } else { - const text = - typeof (raw as { text?: () => Promise }).text === "function" - ? await (raw as { text: () => Promise }).text() - : typeof (raw as { json?: () => Promise }).json === "function" - ? JSON.stringify(await (raw as { json: () => Promise }).json()) - : ""; - body = new TextEncoder().encode(text).buffer; - } - return { - response: new Response(body, { - status: (raw as { status?: number }).status ?? 200, - headers: (raw as { headers?: HeadersInit }).headers, - }), - release: async () => {}, - finalUrl: guardParams.url, - }; - }); - return timeouts; - } - - it("defaults the /message/text send to DEFAULT_SEND_TIMEOUT_MS (30s), not 10s", async () => { - const timeouts = installTimeoutCapture(); - mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg-default-timeout" } }); - - try { - const result = await sendMessageBlueBubbles("+15551234567", "Hello", { - serverUrl: "http://localhost:1234", - password: "test", - }); - expect(result.messageId).toBe("msg-default-timeout"); - // chat/query preflight must stay at the short default; only the send POST rises. - expect(timeouts[0]).toBe(10_000); - expect(timeouts[1]).toBe(30_000); - } finally { - _setFetchGuardForTesting(null); - } - }); - - it("honors channels.bluebubbles.sendTimeoutMs from config for the send POST", async () => { - const timeouts = installTimeoutCapture(); - mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg-config-timeout" } }); - - try { - const result = await sendMessageBlueBubbles("+15551234567", "Hello", { - cfg: { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test", - sendTimeoutMs: 45_000, - }, - }, - }, - }); - expect(result.messageId).toBe("msg-config-timeout"); - // chat/query preflight must stay at the short default; only the send POST rises. - expect(timeouts[0]).toBe(10_000); - expect(timeouts[1]).toBe(45_000); - } finally { - _setFetchGuardForTesting(null); - } - }); - - it("explicit opts.timeoutMs wins over both config and default", async () => { - const timeouts = installTimeoutCapture(); - mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg-explicit-timeout" } }); - - try { - const result = await sendMessageBlueBubbles("+15551234567", "Hello", { - cfg: { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test", - sendTimeoutMs: 45_000, - }, - }, - }, - timeoutMs: 90_000, - }); - expect(result.messageId).toBe("msg-explicit-timeout"); - // Explicit opts.timeoutMs is forwarded to every call site, including - // the chat/query preflight — the only override that can push that - // preflight above the 10s default. - expect(timeouts[0]).toBe(90_000); - expect(timeouts[1]).toBe(90_000); - } finally { - _setFetchGuardForTesting(null); - } - }); - }); - }); - - describe("createChatForHandle", () => { - it("creates a new chat and returns chatGuid from response", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - data: { guid: "iMessage;-;+15559876543", chatGuid: "iMessage;-;+15559876543" }, - }), - ), - }); - - const result = await createChatForHandle({ - baseUrl: "http://localhost:1234", - password: "test", - address: "+15559876543", - message: "Hello!", - }); - - expect(result.chatGuid).toBe("iMessage;-;+15559876543"); - expect(result.messageId).toBeDefined(); - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.addresses).toEqual(["+15559876543"]); - expect(body.message).toBe("Hello!"); - }); - - it("creates a new chat without a message when message is omitted", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - data: { guid: "iMessage;-;+15559876543" }, - }), - ), - }); - - const result = await createChatForHandle({ - baseUrl: "http://localhost:1234", - password: "test", - address: "+15559876543", - }); - - expect(result.chatGuid).toBe("iMessage;-;+15559876543"); - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.message).toBe(""); - }); - - it.each([ - ["data.chatGuid", { data: { chatGuid: "shape-chat-guid" } }, "shape-chat-guid"], - ["data.guid", { data: { guid: "shape-guid" } }, "shape-guid"], - [ - "data.chats[0].guid", - { data: { chats: [{ guid: "shape-array-guid" }] } }, - "shape-array-guid", - ], - ["data.chat.guid", { data: { chat: { guid: "shape-object-guid" } } }, "shape-object-guid"], - ])("extracts chatGuid from %s", async (_label, responseBody, expectedChatGuid) => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(JSON.stringify(responseBody)), - }); - - const result = await createChatForHandle({ - baseUrl: "http://localhost:1234", - password: "test", - address: "+15559876543", - }); - - expect(result.chatGuid).toBe(expectedChatGuid); - }); - - it("throws when Private API is not enabled", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 403, - text: () => Promise.resolve("Private API not enabled"), - }); - - await expect( - createChatForHandle({ - baseUrl: "http://localhost:1234", - password: "test", - address: "+15559876543", - }), - ).rejects.toThrow("Private API must be enabled"); - }); - - it("returns null chatGuid when response has no chat data", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ data: {} })), - }); - - const result = await createChatForHandle({ - baseUrl: "http://localhost:1234", - password: "test", - address: "+15559876543", - message: "Hello", - }); - - expect(result.chatGuid).toBeNull(); - }); - }); -}); diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts deleted file mode 100644 index 46da91ce181d..000000000000 --- a/extensions/bluebubbles/src/send.ts +++ /dev/null @@ -1,679 +0,0 @@ -import crypto from "node:crypto"; -import { - createMessageReceiptFromOutboundResults, - type MessageReceipt, - type MessageReceiptSourceResult, -} from "openclaw/plugin-sdk/channel-message"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalLowercaseString, - normalizeOptionalString, - stripMarkdown, -} from "openclaw/plugin-sdk/text-runtime"; -import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; -import { createBlueBubblesClient, createBlueBubblesClientFromParts } from "./client.js"; -import { - fetchBlueBubblesServerInfo, - getCachedBlueBubblesPrivateApiStatus, - isBlueBubblesPrivateApiStatusEnabled, - isMacOS26OrHigher, -} from "./probe.js"; -import type { OpenClawConfig } from "./runtime-api.js"; -import { warnBlueBubbles } from "./runtime.js"; -import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js"; -import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js"; -import { DEFAULT_SEND_TIMEOUT_MS, type BlueBubblesSendTarget } from "./types.js"; - -export type BlueBubblesSendOpts = { - serverUrl?: string; - password?: string; - accountId?: string; - timeoutMs?: number; - cfg?: OpenClawConfig; - /** Message GUID to reply to (reply threading) */ - replyToMessageGuid?: string; - /** Part index for reply (default: 0) */ - replyToPartIndex?: number; - /** Effect ID or short name for message effects (e.g., "slam", "balloons") */ - effectId?: string; -}; - -export type BlueBubblesSendResult = { - messageId: string; - receipt: MessageReceipt; -}; - -/** Maps short effect names to full Apple effect IDs */ -const EFFECT_MAP: Record = { - // Bubble effects - slam: "com.apple.MobileSMS.expressivesend.impact", - loud: "com.apple.MobileSMS.expressivesend.loud", - gentle: "com.apple.MobileSMS.expressivesend.gentle", - invisible: "com.apple.MobileSMS.expressivesend.invisibleink", - "invisible-ink": "com.apple.MobileSMS.expressivesend.invisibleink", - "invisible ink": "com.apple.MobileSMS.expressivesend.invisibleink", - invisibleink: "com.apple.MobileSMS.expressivesend.invisibleink", - // Screen effects - echo: "com.apple.messages.effect.CKEchoEffect", - spotlight: "com.apple.messages.effect.CKSpotlightEffect", - balloons: "com.apple.messages.effect.CKHappyBirthdayEffect", - confetti: "com.apple.messages.effect.CKConfettiEffect", - love: "com.apple.messages.effect.CKHeartEffect", - heart: "com.apple.messages.effect.CKHeartEffect", - hearts: "com.apple.messages.effect.CKHeartEffect", - lasers: "com.apple.messages.effect.CKLasersEffect", - fireworks: "com.apple.messages.effect.CKFireworksEffect", - celebration: "com.apple.messages.effect.CKSparklesEffect", -}; - -function resolveEffectId(raw?: string): string | undefined { - const trimmed = normalizeOptionalLowercaseString(raw); - if (!trimmed) { - return undefined; - } - if (EFFECT_MAP[trimmed]) { - return EFFECT_MAP[trimmed]; - } - const normalized = trimmed.replace(/[\s_]+/g, "-"); - if (EFFECT_MAP[normalized]) { - return EFFECT_MAP[normalized]; - } - const compact = trimmed.replace(/[\s_-]+/g, ""); - if (EFFECT_MAP[compact]) { - return EFFECT_MAP[compact]; - } - return raw; -} - -type PrivateApiDecision = { - canUsePrivateApi: boolean; - throwEffectDisabledError: boolean; - warningMessage?: string; -}; - -function resolvePrivateApiDecision(params: { - privateApiStatus: boolean | null; - wantsReplyThread: boolean; - wantsEffect: boolean; - accountId?: string; -}): PrivateApiDecision { - const { privateApiStatus, wantsReplyThread, wantsEffect, accountId } = params; - const needsPrivateApi = wantsReplyThread || wantsEffect; - // On macOS 26 Tahoe, AppleScript Messages.app automation is broken - // (`-1700` error) for outbound sends. Prefer Private API even for plain - // text when it is available so sends still reach the recipient. - // (#53159 Bug B, #64480) - const forceOnMacOS26 = - isMacOS26OrHigher(accountId) && isBlueBubblesPrivateApiStatusEnabled(privateApiStatus); - const canUsePrivateApi = - (needsPrivateApi || forceOnMacOS26) && isBlueBubblesPrivateApiStatusEnabled(privateApiStatus); - const throwEffectDisabledError = wantsEffect && privateApiStatus === false; - if (!needsPrivateApi || privateApiStatus !== null) { - return { canUsePrivateApi, throwEffectDisabledError }; - } - const requested = [ - wantsReplyThread ? "reply threading" : null, - wantsEffect ? "message effects" : null, - ] - .filter(Boolean) - .join(" + "); - return { - canUsePrivateApi, - throwEffectDisabledError, - warningMessage: `Private API status unknown; sending without ${requested}. Run a status probe to restore private-api features.`, - }; -} - -function createBlueBubblesSendReceipt(params: { - messageId: string; - chatGuid?: string | null; - replyToMessageGuid?: string; -}): MessageReceipt { - const messageId = params.messageId.trim(); - const results: MessageReceiptSourceResult[] = - messageId && messageId !== "unknown" && messageId !== "ok" - ? [ - { - channel: "bluebubbles", - messageId, - }, - ] - : []; - if (results[0] && params.chatGuid) { - results[0].conversationId = params.chatGuid; - } - return createMessageReceiptFromOutboundResults({ - results, - kind: "text", - ...(params.replyToMessageGuid ? { replyToId: params.replyToMessageGuid } : {}), - }); -} - -async function parseBlueBubblesMessageResponse( - res: Response, - params: { chatGuid?: string | null; replyToMessageGuid?: string } = {}, -): Promise { - const body = await res.text(); - let messageId = "ok"; - if (!body) { - return { - messageId, - receipt: createBlueBubblesSendReceipt({ - messageId, - ...(params.chatGuid ? { chatGuid: params.chatGuid } : {}), - ...(params.replyToMessageGuid ? { replyToMessageGuid: params.replyToMessageGuid } : {}), - }), - }; - } - try { - const parsed = JSON.parse(body) as unknown; - messageId = extractBlueBubblesMessageId(parsed); - } catch { - messageId = "ok"; - } - return { - messageId, - receipt: createBlueBubblesSendReceipt({ - messageId, - ...(params.chatGuid ? { chatGuid: params.chatGuid } : {}), - ...(params.replyToMessageGuid ? { replyToMessageGuid: params.replyToMessageGuid } : {}), - }), - }; -} - -type BlueBubblesChatRecord = Record; - -function extractChatGuid(chat: BlueBubblesChatRecord): string | null { - const candidates = [ - chat.chatGuid, - chat.guid, - chat.chat_guid, - chat.identifier, - chat.chatIdentifier, - chat.chat_identifier, - ]; - for (const candidate of candidates) { - const value = normalizeOptionalString(candidate); - if (value) { - return value; - } - } - return null; -} - -function extractChatId(chat: BlueBubblesChatRecord): number | null { - const candidates = [chat.chatId, chat.id, chat.chat_id]; - for (const candidate of candidates) { - if (typeof candidate === "number" && Number.isFinite(candidate)) { - return candidate; - } - } - return null; -} - -function extractChatIdentifierFromChatGuid(chatGuid: string): string | null { - const parts = chatGuid.split(";"); - if (parts.length < 3) { - return null; - } - return normalizeOptionalString(parts[2]) ?? null; -} - -function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] { - const raw = - (Array.isArray(chat.participants) ? chat.participants : null) ?? - (Array.isArray(chat.handles) ? chat.handles : null) ?? - (Array.isArray(chat.participantHandles) ? chat.participantHandles : null); - if (!raw) { - return []; - } - const out: string[] = []; - for (const entry of raw) { - if (typeof entry === "string") { - out.push(entry); - continue; - } - if (entry && typeof entry === "object") { - const record = entry as Record; - const candidate = - (typeof record.address === "string" && record.address) || - (typeof record.handle === "string" && record.handle) || - (typeof record.id === "string" && record.id) || - (typeof record.identifier === "string" && record.identifier); - if (candidate) { - out.push(candidate); - } - } - } - return out; -} - -async function queryChats(params: { - baseUrl: string; - password: string; - timeoutMs?: number; - offset: number; - limit: number; - allowPrivateNetwork?: boolean; -}): Promise { - const client = createBlueBubblesClientFromParts({ - baseUrl: params.baseUrl, - password: params.password, - allowPrivateNetwork: params.allowPrivateNetwork === true, - timeoutMs: params.timeoutMs, - }); - const res = await client.request({ - method: "POST", - path: "/api/v1/chat/query", - body: { - limit: params.limit, - offset: params.offset, - with: ["participants"], - }, - timeoutMs: params.timeoutMs, - }); - if (!res.ok) { - return []; - } - const payload = (await res.json().catch(() => null)) as Record | null; - const data = payload && payload.data !== undefined ? (payload.data as unknown) : null; - return Array.isArray(data) ? (data as BlueBubblesChatRecord[]) : []; -} - -export async function resolveChatGuidForTarget(params: { - baseUrl: string; - password: string; - timeoutMs?: number; - target: BlueBubblesSendTarget; - allowPrivateNetwork?: boolean; -}): Promise { - if (params.target.kind === "chat_guid") { - return params.target.chatGuid; - } - - const normalizedHandle = - params.target.kind === "handle" ? normalizeBlueBubblesHandle(params.target.address) : ""; - const targetChatId = params.target.kind === "chat_id" ? params.target.chatId : null; - const targetChatIdentifier = - params.target.kind === "chat_identifier" ? params.target.chatIdentifier : null; - - const limit = 500; - // When matching by handle, prefer the caller's requested service. A user may - // have both an `iMessage;-;` and `SMS;-;` chat: - // - default / `service: "imessage"` / `service: "auto"` -> prefer iMessage - // so we never silently downgrade to SMS when iMessage is available. - // - explicit `service: "sms"` (e.g. caller passed `sms:+15551234567`) -> - // prefer SMS so explicit SMS intent is respected. - // - // A direct `;-;` match is the strongest signal and - // returns immediately. Everything else is recorded as a ranked fallback. - const preferredService: "iMessage" | "SMS" = - params.target.kind === "handle" && params.target.service === "sms" ? "SMS" : "iMessage"; - const preferredPrefix = `${preferredService};-;`; - const otherPrefix = preferredService === "iMessage" ? "SMS;-;" : "iMessage;-;"; - - // Note: a direct `preferredPrefix` match `return`s immediately below, so we - // only need to remember the other-service and unknown-service direct fallbacks. - let directHandleOtherServiceMatch: string | null = null; - let directHandleUnknownServiceMatch: string | null = null; - let participantPreferredMatch: string | null = null; - let participantOtherServiceMatch: string | null = null; - let participantUnknownServiceMatch: string | null = null; - for (let offset = 0; offset < 5000; offset += limit) { - const chats = await queryChats({ - baseUrl: params.baseUrl, - password: params.password, - timeoutMs: params.timeoutMs, - offset, - limit, - allowPrivateNetwork: params.allowPrivateNetwork, - }); - if (chats.length === 0) { - break; - } - for (const chat of chats) { - if (targetChatId != null) { - const chatId = extractChatId(chat); - if (chatId != null && chatId === targetChatId) { - return extractChatGuid(chat); - } - } - if (targetChatIdentifier) { - const guid = extractChatGuid(chat); - if (guid) { - // Back-compat: some callers might pass a full chat GUID. - if (guid === targetChatIdentifier) { - return guid; - } - - // Primary match: BlueBubbles `chat_identifier:*` targets correspond to the - // third component of the chat GUID: `service;(+|-) ;identifier`. - const guidIdentifier = extractChatIdentifierFromChatGuid(guid); - if (guidIdentifier && guidIdentifier === targetChatIdentifier) { - return guid; - } - } - - const identifier = - typeof chat.identifier === "string" - ? chat.identifier - : typeof chat.chatIdentifier === "string" - ? chat.chatIdentifier - : typeof chat.chat_identifier === "string" - ? chat.chat_identifier - : ""; - if (identifier && identifier === targetChatIdentifier) { - return guid ?? extractChatGuid(chat); - } - } - if (normalizedHandle) { - const guid = extractChatGuid(chat); - const directHandle = guid ? extractHandleFromChatGuid(guid) : null; - if (directHandle && directHandle === normalizedHandle && guid) { - // A direct `` is the strongest signal and we - // can return immediately. Other services are remembered as fallbacks - // and we keep scanning in case a preferred-service chat exists later. - if (guid.startsWith(preferredPrefix)) { - return guid; - } - if (guid.startsWith(otherPrefix)) { - if (!directHandleOtherServiceMatch) { - directHandleOtherServiceMatch = guid; - } - } else if (!directHandleUnknownServiceMatch) { - // Unknown service; treat as a last-resort direct match. - directHandleUnknownServiceMatch = guid; - } - } - if (guid) { - // Only consider DM chats (`;-;` separator) as participant matches. - // Group chats (`;+;` separator) should never match when searching by handle/phone. - // This prevents routing "send to +1234567890" to a group chat that contains that number. - const isDmChat = guid.includes(";-;"); - if (isDmChat) { - const participants = extractParticipantAddresses(chat).map((entry) => - normalizeBlueBubblesHandle(entry), - ); - if (participants.includes(normalizedHandle)) { - if (guid.startsWith(preferredPrefix)) { - if (!participantPreferredMatch) { - participantPreferredMatch = guid; - } - } else if (guid.startsWith(otherPrefix)) { - if (!participantOtherServiceMatch) { - participantOtherServiceMatch = guid; - } - } else if (!participantUnknownServiceMatch) { - participantUnknownServiceMatch = guid; - } - } - } - } - } - } - // We deliberately do NOT break early on participant or non-preferred direct - // matches: a higher-priority direct `` chat may - // still exist on a later page, and only that branch can short-circuit. - } - return ( - participantPreferredMatch ?? - directHandleOtherServiceMatch ?? - participantOtherServiceMatch ?? - directHandleUnknownServiceMatch ?? - participantUnknownServiceMatch - ); -} - -/** - * Creates a new DM chat for the given address and returns the chat GUID. - * Requires Private API to be enabled in BlueBubbles. - * - * If a `message` is provided it is sent as the initial message in the new chat; - * otherwise an empty-string message body is used (BlueBubbles still creates the - * chat but will not deliver a visible bubble). - */ -export async function createChatForHandle(params: { - baseUrl: string; - password: string; - address: string; - message?: string; - timeoutMs?: number; - allowPrivateNetwork?: boolean; -}): Promise<{ chatGuid: string | null; messageId: string }> { - const client = createBlueBubblesClientFromParts({ - baseUrl: params.baseUrl, - password: params.password, - allowPrivateNetwork: params.allowPrivateNetwork === true, - timeoutMs: params.timeoutMs, - }); - const payload = { - addresses: [params.address], - message: params.message ?? "", - tempGuid: `temp-${crypto.randomUUID()}`, - }; - const res = await client.request({ - method: "POST", - path: "/api/v1/chat/new", - body: payload, - timeoutMs: params.timeoutMs, - }); - if (!res.ok) { - const errorText = await res.text(); - if ( - res.status === 400 || - res.status === 403 || - normalizeLowercaseStringOrEmpty(errorText).includes("private api") - ) { - throw new Error( - `BlueBubbles send failed: Cannot create new chat - Private API must be enabled. Original error: ${errorText || res.status}`, - ); - } - throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`); - } - const body = await res.text(); - let messageId = "ok"; - let chatGuid: string | null = null; - if (body) { - try { - const parsed = JSON.parse(body) as Record; - messageId = extractBlueBubblesMessageId(parsed); - // Extract chatGuid from the response data - const data = parsed.data as Record | undefined; - if (data) { - chatGuid = - (typeof data.chatGuid === "string" && data.chatGuid) || - (typeof data.guid === "string" && data.guid) || - null; - // Also try nested chats array (some BB versions nest it) - if (!chatGuid) { - const chats = data.chats ?? data.chat; - if (Array.isArray(chats) && chats.length > 0) { - const first = chats[0] as Record | undefined; - chatGuid = - (typeof first?.guid === "string" && first.guid) || - (typeof first?.chatGuid === "string" && first.chatGuid) || - null; - } else if (chats && typeof chats === "object" && !Array.isArray(chats)) { - const chatObj = chats as Record; - chatGuid = - (typeof chatObj.guid === "string" && chatObj.guid) || - (typeof chatObj.chatGuid === "string" && chatObj.chatGuid) || - null; - } - } - } - } catch { - // ignore parse errors - } - } - return { chatGuid, messageId }; -} - -/** - * Creates a new chat (DM) and sends an initial message. - * Requires Private API to be enabled in BlueBubbles. - */ -async function createNewChatWithMessage(params: { - baseUrl: string; - password: string; - address: string; - message: string; - timeoutMs?: number; - allowPrivateNetwork?: boolean; -}): Promise { - const result = await createChatForHandle({ - baseUrl: params.baseUrl, - password: params.password, - address: params.address, - message: params.message, - timeoutMs: params.timeoutMs, - allowPrivateNetwork: params.allowPrivateNetwork, - }); - return { - messageId: result.messageId, - receipt: createBlueBubblesSendReceipt({ - messageId: result.messageId, - chatGuid: result.chatGuid, - }), - }; -} - -export async function sendMessageBlueBubbles( - to: string, - text: string, - opts: BlueBubblesSendOpts = {}, -): Promise { - const trimmedText = text ?? ""; - if (!trimmedText.trim()) { - throw new Error("BlueBubbles send requires text"); - } - // Strip markdown early and validate - ensures messages like "***" or "---" don't become empty - const strippedText = stripMarkdown(trimmedText); - if (!strippedText.trim()) { - throw new Error("BlueBubbles send requires text (message was empty after markdown removal)"); - } - - const { baseUrl, password, accountId, allowPrivateNetwork, sendTimeoutMs } = - resolveBlueBubblesServerAccount({ - cfg: opts.cfg ?? {}, - accountId: opts.accountId, - serverUrl: opts.serverUrl, - password: opts.password, - }); - // Send-path timeout: explicit caller override > per-account config > 30s default. - // Kept separate from the default 10s client timeout so chat lookups, probes, - // and health checks stay snappy while actual sends can ride out macOS 26 - // Private API stalls. (#67486) - const effectiveSendTimeoutMs = opts.timeoutMs ?? sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS; - let privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId); - - const target = resolveBlueBubblesSendTarget(to); - const chatGuid = await resolveChatGuidForTarget({ - baseUrl, - password, - timeoutMs: opts.timeoutMs, - target, - allowPrivateNetwork, - }); - if (!chatGuid) { - // If target is a phone number/handle and no existing chat found, - // auto-create a new DM chat using the /api/v1/chat/new endpoint - if (target.kind === "handle") { - return createNewChatWithMessage({ - baseUrl, - password, - address: target.address, - message: strippedText, - timeoutMs: effectiveSendTimeoutMs, - allowPrivateNetwork, - }); - } - throw new Error( - "BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.", - ); - } - const effectId = resolveEffectId(opts.effectId); - const wantsReplyThread = normalizeOptionalString(opts.replyToMessageGuid) !== undefined; - const wantsEffect = Boolean(effectId); - - // Lazy refresh: when the cache has expired, fetch server info before - // making the decision. Originally scoped to reply/effect features (#43764) - // to avoid silent degradation after the 10-minute cache TTL expires. Now - // always fires on null status, because `isMacOS26OrHigher()` reads from - // the same cache and plain-text sends on macOS 26 need Private API too — - // without this, `forceOnMacOS26` silently falls back to broken AppleScript - // after TTL expiry or on a cold cache. (#64480, Greptile/Codex PR #69070) - if (privateApiStatus === null) { - try { - await fetchBlueBubblesServerInfo({ - baseUrl, - password, - accountId, - timeoutMs: opts.timeoutMs ?? 5000, - allowPrivateNetwork, - }); - privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId); - } catch { - // Refresh failed — proceed with null status (existing graceful degradation) - } - } - - const privateApiDecision = resolvePrivateApiDecision({ - privateApiStatus, - wantsReplyThread, - wantsEffect, - accountId, - }); - if (privateApiDecision.throwEffectDisabledError) { - throw new Error( - "BlueBubbles send failed: reply/effect requires Private API, but it is disabled on the BlueBubbles server.", - ); - } - if (privateApiDecision.warningMessage) { - warnBlueBubbles(privateApiDecision.warningMessage); - } - // Always set `method` explicitly. BB Server's behavior on an omitted - // `method` is version-dependent and silently drops on some setups (e.g. - // macOS without Private API — message lands in Messages.app locally but - // never reaches the phone). (#64480) - const payload: Record = { - chatGuid, - tempGuid: crypto.randomUUID(), - message: strippedText, - method: privateApiDecision.canUsePrivateApi ? "private-api" : "apple-script", - }; - - // Add reply threading support - if (wantsReplyThread && privateApiDecision.canUsePrivateApi) { - payload.selectedMessageGuid = opts.replyToMessageGuid; - payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0; - } - - // Add message effects support - if (effectId && privateApiDecision.canUsePrivateApi) { - payload.effectId = effectId; - } - - const client = createBlueBubblesClient({ - cfg: opts.cfg ?? {}, - accountId: opts.accountId, - serverUrl: opts.serverUrl, - password: opts.password, - }); - const res = await client.request({ - method: "POST", - path: "/api/v1/message/text", - body: payload, - timeoutMs: effectiveSendTimeoutMs, - }); - if (!res.ok) { - const errorText = await res.text(); - throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`); - } - return parseBlueBubblesMessageResponse(res, { - chatGuid, - ...(wantsReplyThread && opts.replyToMessageGuid - ? { replyToMessageGuid: opts.replyToMessageGuid } - : {}), - }); -} diff --git a/extensions/bluebubbles/src/session-route.test.ts b/extensions/bluebubbles/src/session-route.test.ts deleted file mode 100644 index 59cdfd1f2489..000000000000 --- a/extensions/bluebubbles/src/session-route.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "./runtime-api.js"; -import { resolveBlueBubblesOutboundSessionRoute } from "./session-route.js"; - -const EMPTY_CFG = {} as OpenClawConfig; -const PER_PEER_CFG = { - session: { dmScope: "per-peer" }, -} as OpenClawConfig; - -function call(target: string, cfg = EMPTY_CFG) { - return resolveBlueBubblesOutboundSessionRoute({ - cfg, - agentId: "agent-1", - accountId: "default", - target, - }); -} - -describe("resolveBlueBubblesOutboundSessionRoute DM/group disambiguation", () => { - it("treats `chat_guid:` with `;-;` marker as a DM", () => { - // Candidate-2 regression: the previous implementation classified ANY - // chat_guid-prefixed target as a group, even DMs (BlueBubbles encodes - // DM chatGuids as `service;-;handle`). That made the same DM resolve - // to one sessionKey via handle form (`+15551234567`) and a different - // sessionKey via chat_guid form (`chat_guid:iMessage;-;+15551234567`), - // causing bound DM sessions to mis-route into a freshly synthesized - // "group" session key. - const route = call("bluebubbles:chat_guid:iMessage;-;+15551234567"); - expect(route).not.toBeNull(); - expect(route?.peer.kind).toBe("direct"); - expect(route?.peer.id).toBe("+15551234567"); - expect(route?.chatType).toBe("direct"); - expect(route?.from).toBe("bluebubbles:+15551234567"); - expect(route?.to).toBe("bluebubbles:chat_guid:iMessage;-;+15551234567"); - expect(route?.from).not.toMatch(/^group:/); - }); - - it("treats `chat_guid:` with `;+;` marker as a group", () => { - const route = call("bluebubbles:chat_guid:iMessage;+;chat-known-123"); - expect(route).not.toBeNull(); - expect(route?.peer.kind).toBe("group"); - expect(route?.chatType).toBe("group"); - expect(route?.from).toMatch(/^group:/); - }); - - it("falls back to group when chat_guid lacks a recognizable marker", () => { - // Backwards-compatible default: pre-fix behavior was to treat all - // chat_guid forms as group. Preserve that for unknown shapes so we - // do not silently downgrade an actual group to direct. - const route = call("bluebubbles:chat_guid:weird-no-semicolons"); - expect(route).not.toBeNull(); - expect(route?.peer.kind).toBe("group"); - }); - - it("treats handle targets as direct", () => { - const route = call("bluebubbles:imessage:+15551234567"); - expect(route).not.toBeNull(); - expect(route?.peer.kind).toBe("direct"); - expect(route?.from).toMatch(/^bluebubbles:/); - }); - - it("keeps chat_id targets classified as group", () => { - const route = call("bluebubbles:chat_id:42"); - expect(route).not.toBeNull(); - expect(route?.peer.kind).toBe("group"); - expect(route?.peer.id).toBe("42"); - }); - - it("keeps chat_identifier targets classified as group", () => { - const route = call("bluebubbles:chat_identifier:chat-abc"); - expect(route).not.toBeNull(); - expect(route?.peer.kind).toBe("group"); - expect(route?.peer.id).toBe("chat-abc"); - }); - - it("DM via chat_guid and DM via handle land on the same session key", () => { - // The point of disambiguation: a DM addressed two different ways must - // converge on the same sessionKey so existing bindings keep matching. - const handleRoute = call("bluebubbles:imessage:+15551234567", PER_PEER_CFG); - const chatGuidRoute = call("bluebubbles:chat_guid:iMessage;-;+15551234567", PER_PEER_CFG); - expect(handleRoute?.sessionKey).toBeDefined(); - expect(chatGuidRoute?.sessionKey).toBeDefined(); - expect(handleRoute?.peer.kind).toBe(chatGuidRoute?.peer.kind); - expect(handleRoute?.peer.id).toBe(chatGuidRoute?.peer.id); - expect(handleRoute?.from).toBe(chatGuidRoute?.from); - expect(handleRoute?.sessionKey).toBe(chatGuidRoute?.sessionKey); - expect(chatGuidRoute?.to).toBe("bluebubbles:chat_guid:iMessage;-;+15551234567"); - }); -}); diff --git a/extensions/bluebubbles/src/session-route.ts b/extensions/bluebubbles/src/session-route.ts deleted file mode 100644 index 42adfd3c3a2f..000000000000 --- a/extensions/bluebubbles/src/session-route.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - buildChannelOutboundSessionRoute, - stripChannelTargetPrefix, - type ChannelOutboundSessionRouteParams, -} from "openclaw/plugin-sdk/channel-core"; -import { resolveGroupFlagFromChatGuid } from "./monitor-normalize.js"; -import { extractHandleFromChatGuid, parseBlueBubblesTarget } from "./targets.js"; - -export function resolveBlueBubblesOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) { - const stripped = stripChannelTargetPrefix(params.target, "bluebubbles"); - if (!stripped) { - return null; - } - const parsed = parseBlueBubblesTarget(stripped); - // chat_guid carries an explicit DM-vs-group marker (`;-;` for DMs, - // `;+;` for groups). Honor it so the same DM does not get one - // sessionKey for handle-form targets (`imessage:+1234`) and a - // different one for chat_guid-form targets - // (`chat_guid:iMessage;-;+1234`) — that mismatch made bound DM - // sessions mis-route the outbound back into a freshly-created - // "group" sessionKey. - const groupFromChatGuid = - parsed.kind === "chat_guid" ? resolveGroupFlagFromChatGuid(parsed.chatGuid) : undefined; - const isGroup = - parsed.kind === "chat_id" || parsed.kind === "chat_identifier" - ? true - : parsed.kind === "chat_guid" - ? (groupFromChatGuid ?? true) - : false; - const dmHandleFromChatGuid = - parsed.kind === "chat_guid" && groupFromChatGuid === false - ? extractHandleFromChatGuid(parsed.chatGuid) - : null; - const peerId = - parsed.kind === "chat_id" - ? String(parsed.chatId) - : parsed.kind === "chat_guid" - ? (dmHandleFromChatGuid ?? parsed.chatGuid) - : parsed.kind === "chat_identifier" - ? parsed.chatIdentifier - : parsed.to; - return buildChannelOutboundSessionRoute({ - cfg: params.cfg, - agentId: params.agentId, - channel: "bluebubbles", - accountId: params.accountId, - peer: { - kind: isGroup ? "group" : "direct", - id: peerId, - }, - chatType: isGroup ? "group" : "direct", - from: isGroup ? `group:${peerId}` : `bluebubbles:${peerId}`, - to: `bluebubbles:${stripped}`, - }); -} diff --git a/extensions/bluebubbles/src/setup-core.ts b/extensions/bluebubbles/src/setup-core.ts deleted file mode 100644 index dc334164ae49..000000000000 --- a/extensions/bluebubbles/src/setup-core.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { - addWildcardAllowFrom, - createSetupInputPresenceValidator, - normalizeAccountId, - patchScopedAccountConfig, - prepareScopedSetupConfig, - type ChannelSetupAdapter, - type DmPolicy, - type OpenClawConfig, -} from "openclaw/plugin-sdk/setup"; -import { applyBlueBubblesConnectionConfig } from "./config-apply.js"; - -const channel = "bluebubbles" as const; - -export function setBlueBubblesDmPolicy( - cfg: OpenClawConfig, - accountId: string, - dmPolicy: DmPolicy, -): OpenClawConfig { - const resolvedAccountId = normalizeAccountId(accountId); - const existingAllowFrom = - resolvedAccountId === "default" - ? cfg.channels?.bluebubbles?.allowFrom - : (( - cfg.channels?.bluebubbles?.accounts?.[resolvedAccountId] as - | { allowFrom?: ReadonlyArray } - | undefined - )?.allowFrom ?? cfg.channels?.bluebubbles?.allowFrom); - return patchScopedAccountConfig({ - cfg, - channelKey: channel, - accountId: resolvedAccountId, - patch: { - dmPolicy, - ...(dmPolicy === "open" ? { allowFrom: addWildcardAllowFrom(existingAllowFrom) } : {}), - }, - ensureChannelEnabled: false, - ensureAccountEnabled: false, - }); -} - -export function setBlueBubblesAllowFrom( - cfg: OpenClawConfig, - accountId: string, - allowFrom: string[], -): OpenClawConfig { - return patchScopedAccountConfig({ - cfg, - channelKey: channel, - accountId, - patch: { allowFrom }, - ensureChannelEnabled: false, - ensureAccountEnabled: false, - }); -} - -export const blueBubblesSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - prepareScopedSetupConfig({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: createSetupInputPresenceValidator({ - validate: ({ input }) => { - if (!input.httpUrl && !input.password) { - return "BlueBubbles requires --http-url and --password."; - } - if (!input.httpUrl) { - return "BlueBubbles requires --http-url."; - } - if (!input.password) { - return "BlueBubbles requires --password."; - } - return null; - }, - }), - applyAccountConfig: ({ cfg, accountId, input }) => { - const next = prepareScopedSetupConfig({ - cfg, - channelKey: channel, - accountId, - name: input.name, - migrateBaseName: true, - }); - return applyBlueBubblesConnectionConfig({ - cfg: next, - accountId, - patch: { - serverUrl: input.httpUrl, - password: input.password, - webhookPath: input.webhookPath, - }, - onlyDefinedFields: true, - }); - }, -}; diff --git a/extensions/bluebubbles/src/setup-surface.test.ts b/extensions/bluebubbles/src/setup-surface.test.ts deleted file mode 100644 index da6fac2b6223..000000000000 --- a/extensions/bluebubbles/src/setup-surface.test.ts +++ /dev/null @@ -1,895 +0,0 @@ -import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; -import { - createSetupWizardAdapter, - createTestWizardPrompter, - runSetupWizardConfigure, -} from "openclaw/plugin-sdk/plugin-test-runtime"; -import type { WizardPrompter } from "openclaw/plugin-sdk/plugin-test-runtime"; -import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; -import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; -import { describe, expect, it, vi } from "vitest"; -import { resolveBlueBubblesAccount } from "./accounts.js"; -import { BlueBubblesConfigSchema } from "./config-schema.js"; -import { - resolveBlueBubblesGroupRequireMention, - resolveBlueBubblesGroupToolPolicy, -} from "./group-policy.js"; -import { blueBubblesSetupAdapter, blueBubblesSetupWizard } from "./setup-surface.js"; -import { - inferBlueBubblesTargetChatType, - isAllowedBlueBubblesSender, - looksLikeBlueBubblesExplicitTargetId, - looksLikeBlueBubblesTargetId, - normalizeBlueBubblesMessagingTarget, - parseBlueBubblesAllowTarget, - parseBlueBubblesTarget, -} from "./targets.js"; -import { DEFAULT_WEBHOOK_PATH } from "./webhook-shared.js"; - -async function createBlueBubblesConfigureAdapter() { - const plugin = { - id: "bluebubbles", - meta: { - id: "bluebubbles", - label: "BlueBubbles", - selectionLabel: "BlueBubbles", - docsPath: "/channels/bluebubbles", - blurb: "iMessage via BlueBubbles", - }, - capabilities: { - chatTypes: ["direct", "group"], - }, - config: { - listAccountIds: () => [DEFAULT_ACCOUNT_ID], - defaultAccountId: () => DEFAULT_ACCOUNT_ID, - resolveAccount: adaptScopedAccountAccessor(resolveBlueBubblesAccount), - resolveAllowFrom: ({ cfg, accountId }: { cfg: unknown; accountId: string }) => - resolveBlueBubblesAccount({ - cfg: cfg as Parameters[0]["cfg"], - accountId, - }).config.allowFrom ?? [], - }, - setup: blueBubblesSetupAdapter, - } as Parameters[0]["plugin"]; - return createSetupWizardAdapter({ - plugin, - wizard: blueBubblesSetupWizard, - }); -} - -async function runBlueBubblesConfigure(params: { cfg: unknown; prompter: WizardPrompter }) { - const adapter = await createBlueBubblesConfigureAdapter(); - type ConfigureContext = Parameters>[0]; - return await runSetupWizardConfigure({ - configure: adapter.configure, - cfg: params.cfg as ConfigureContext["cfg"], - runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"], - prompter: params.prompter, - }); -} - -describe("bluebubbles setup surface", () => { - it("preserves existing password SecretRef and keeps default webhook path", async () => { - const passwordRef = { source: "env", provider: "default", id: "BLUEBUBBLES_PASSWORD" }; - const confirm = vi - .fn() - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true); - const text = vi.fn(); - - const result = await runBlueBubblesConfigure({ - cfg: { - channels: { - bluebubbles: { - enabled: true, - serverUrl: "http://127.0.0.1:1234", - password: passwordRef, - }, - }, - }, - prompter: createTestWizardPrompter({ confirm, text }), - }); - - expect(result.cfg.channels?.bluebubbles?.password).toEqual(passwordRef); - expect(result.cfg.channels?.bluebubbles?.webhookPath).toBe(DEFAULT_WEBHOOK_PATH); - expect(text).not.toHaveBeenCalled(); - }); - - it("applies a custom webhook path when requested", async () => { - const confirm = vi - .fn() - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true); - const text = vi.fn().mockResolvedValueOnce("/custom-bluebubbles"); - - const result = await runBlueBubblesConfigure({ - cfg: { - channels: { - bluebubbles: { - enabled: true, - serverUrl: "http://127.0.0.1:1234", - password: "secret", - }, - }, - }, - prompter: createTestWizardPrompter({ confirm, text }), - }); - - expect(result.cfg.channels?.bluebubbles?.webhookPath).toBe("/custom-bluebubbles"); - expect(text).toHaveBeenCalledWith( - expect.objectContaining({ - message: "Webhook path", - placeholder: DEFAULT_WEBHOOK_PATH, - }), - ); - }); - - it("validates server URLs before accepting input", async () => { - const confirm = vi.fn().mockResolvedValueOnce(false); - const text = vi.fn().mockResolvedValueOnce("127.0.0.1:1234").mockResolvedValueOnce("secret"); - - await runBlueBubblesConfigure({ - cfg: { channels: { bluebubbles: {} } }, - prompter: createTestWizardPrompter({ confirm, text }), - }); - - const serverUrlPrompt = text.mock.calls[0]?.[0] as { - validate?: (value: string) => string | undefined; - }; - expect(serverUrlPrompt.validate?.("bad url")).toBe("Invalid URL format"); - expect(serverUrlPrompt.validate?.("127.0.0.1:1234")).toBeUndefined(); - }); - - it("disables the channel through the setup wizard", async () => { - const next = blueBubblesSetupWizard.disable?.({ - channels: { - bluebubbles: { - enabled: true, - serverUrl: "http://127.0.0.1:1234", - }, - }, - }); - - expect(next?.channels?.bluebubbles?.enabled).toBe(false); - }); - - it("reads the named-account DM policy instead of the channel root", async () => { - expect( - blueBubblesSetupWizard.dmPolicy?.getCurrent( - { - channels: { - bluebubbles: { - dmPolicy: "disabled", - accounts: { - work: { - serverUrl: "http://localhost:1234", - password: "secret", - dmPolicy: "allowlist", - }, - }, - }, - }, - }, - "work", - ), - ).toBe("allowlist"); - }); - - it("reports account-scoped config keys for named accounts", async () => { - expect(blueBubblesSetupWizard.dmPolicy?.resolveConfigKeys?.({}, "work")).toEqual({ - policyKey: "channels.bluebubbles.accounts.work.dmPolicy", - allowFromKey: "channels.bluebubbles.accounts.work.allowFrom", - }); - }); - - it("uses configured defaultAccount for omitted DM policy account context", async () => { - const cfg = { - channels: { - bluebubbles: { - defaultAccount: "work", - dmPolicy: "disabled", - allowFrom: ["user@example.com"], - accounts: { - work: { - serverUrl: "http://localhost:1234", - password: "secret", - dmPolicy: "allowlist", - }, - }, - }, - }, - } as OpenClawConfig; - - expect(blueBubblesSetupWizard.dmPolicy?.getCurrent(cfg)).toBe("allowlist"); - expect(blueBubblesSetupWizard.dmPolicy?.resolveConfigKeys?.(cfg)).toEqual({ - policyKey: "channels.bluebubbles.accounts.work.dmPolicy", - allowFromKey: "channels.bluebubbles.accounts.work.allowFrom", - }); - - const next = blueBubblesSetupWizard.dmPolicy?.setPolicy(cfg, "open"); - const workAccount = next?.channels?.bluebubbles?.accounts?.work as - | { - dmPolicy?: string; - } - | undefined; - expect(next?.channels?.bluebubbles?.dmPolicy).toBe("disabled"); - expect(workAccount?.dmPolicy).toBe("open"); - }); - - it("uses configured defaultAccount when accountId is omitted in account resolution", async () => { - const resolved = resolveBlueBubblesAccount({ - cfg: { - channels: { - bluebubbles: { - defaultAccount: "work", - serverUrl: "http://localhost:3000", - password: "top-secret", - accounts: { - work: { - serverUrl: "http://localhost:1234", - password: "secret", - name: "Work", - }, - }, - }, - }, - } as OpenClawConfig, - }); - - expect(resolved.accountId).toBe("work"); - expect(resolved.name).toBe("Work"); - expect(resolved.baseUrl).toBe("http://localhost:1234"); - expect(resolved.configured).toBe(true); - }); - - it("uses configured defaultAccount for omitted setup configured state", async () => { - const configured = await blueBubblesSetupWizard.status.resolveConfigured({ - cfg: { - channels: { - bluebubbles: { - defaultAccount: "work", - serverUrl: "http://localhost:3000", - password: "top-secret", - accounts: { - alerts: { - serverUrl: "http://localhost:4000", - password: "alerts-secret", - }, - work: { - serverUrl: "", - password: "", - }, - }, - }, - }, - } as OpenClawConfig, - }); - - expect(configured).toBe(false); - }); - - it('writes open policy state to the named account and preserves inherited allowFrom with "*"', async () => { - const next = blueBubblesSetupWizard.dmPolicy?.setPolicy( - { - channels: { - bluebubbles: { - allowFrom: ["user@example.com"], - accounts: { - work: { - serverUrl: "http://localhost:1234", - password: "secret", - }, - }, - }, - }, - }, - "open", - "work", - ); - - const workAccount = next?.channels?.bluebubbles?.accounts?.work as - | { - dmPolicy?: string; - allowFrom?: string[]; - } - | undefined; - expect(next?.channels?.bluebubbles?.dmPolicy).toBeUndefined(); - expect(workAccount?.dmPolicy).toBe("open"); - expect(workAccount?.allowFrom).toEqual(["user@example.com", "*"]); - }); -}); - -describe("resolveBlueBubblesAccount", () => { - it("treats SecretRef passwords as configured when serverUrl exists", () => { - const resolved = resolveBlueBubblesAccount({ - cfg: { - channels: { - bluebubbles: { - enabled: true, - serverUrl: "http://localhost:1234", - password: { - source: "env", - provider: "default", - id: "BLUEBUBBLES_PASSWORD", - }, - }, - }, - }, - }); - - expect(resolved.configured).toBe(true); - expect(resolved.baseUrl).toBe("http://localhost:1234"); - }); - - it("inherits channel-level replyContextApiFallback for accounts that omit the flag (#71820)", () => { - // Codex P2: a per-account `.default(false)` would clobber channel-level - // `replyContextApiFallback: true` during the merge, so multi-account - // operators flipping the global toggle would silently get nothing - // unless they duplicated the flag under every `accounts.` block. - // Verify the runtime resolver actually picks up the channel value. - const resolved = resolveBlueBubblesAccount({ - cfg: { - channels: { - bluebubbles: { - replyContextApiFallback: true, - accounts: { - work: { - serverUrl: "http://localhost:1234", - password: "secret", // pragma: allowlist secret - }, - }, - }, - }, - }, - accountId: "work", - }); - - expect(resolved.config.replyContextApiFallback).toBe(true); - }); - - it("lets account-level replyContextApiFallback override channel-level (#71820)", () => { - const resolved = resolveBlueBubblesAccount({ - cfg: { - channels: { - bluebubbles: { - replyContextApiFallback: true, - accounts: { - work: { - serverUrl: "http://localhost:1234", - password: "secret", // pragma: allowlist secret - replyContextApiFallback: false, - }, - }, - }, - }, - }, - accountId: "work", - }); - - expect(resolved.config.replyContextApiFallback).toBe(false); - }); - - it("strips stale legacy private-network aliases after canonical normalization", () => { - const resolved = resolveBlueBubblesAccount({ - cfg: { - channels: { - bluebubbles: { - network: { - allowPrivateNetwork: true, - }, - accounts: { - work: { - serverUrl: "http://localhost:1234", - password: "secret", // pragma: allowlist secret - network: { - dangerouslyAllowPrivateNetwork: false, - }, - }, - }, - }, - }, - }, - accountId: "work", - }); - - expect(resolved.config.network).toEqual({ - dangerouslyAllowPrivateNetwork: false, - }); - expect("allowPrivateNetwork" in resolved.config).toBe(false); - expect(isPrivateNetworkOptInEnabled(resolved.config)).toBe(false); - }); -}); - -describe("BlueBubblesConfigSchema", () => { - it("accepts account config when serverUrl and password are both set", () => { - const parsed = BlueBubblesConfigSchema.safeParse({ - serverUrl: "http://localhost:1234", - password: "secret", // pragma: allowlist secret - }); - expect(parsed.success).toBe(true); - }); - - it("accepts SecretRef password when serverUrl is set", () => { - const parsed = BlueBubblesConfigSchema.safeParse({ - serverUrl: "http://localhost:1234", - password: { - source: "env", - provider: "default", - id: "BLUEBUBBLES_PASSWORD", - }, - }); - expect(parsed.success).toBe(true); - }); - - it("requires password when top-level serverUrl is configured", () => { - const parsed = BlueBubblesConfigSchema.safeParse({ - serverUrl: "http://localhost:1234", - }); - expect(parsed.success).toBe(false); - if (parsed.success) { - return; - } - expect(parsed.error.issues[0]?.path).toEqual(["password"]); - expect(parsed.error.issues[0]?.message).toBe( - "password is required when serverUrl is configured", - ); - }); - - it("requires password when account serverUrl is configured", () => { - const parsed = BlueBubblesConfigSchema.safeParse({ - accounts: { - work: { - serverUrl: "http://localhost:1234", - }, - }, - }); - expect(parsed.success).toBe(false); - if (parsed.success) { - return; - } - expect(parsed.error.issues[0]?.path).toEqual(["accounts", "work", "password"]); - expect(parsed.error.issues[0]?.message).toBe( - "password is required when serverUrl is configured", - ); - }); - - it("allows password omission when serverUrl is not configured", () => { - const parsed = BlueBubblesConfigSchema.safeParse({ - accounts: { - work: { - name: "Work iMessage", - }, - }, - }); - expect(parsed.success).toBe(true); - }); - - it("defaults enrichGroupParticipantsFromContacts to true", () => { - const parsed = BlueBubblesConfigSchema.safeParse({ - serverUrl: "http://localhost:1234", - password: "secret", // pragma: allowlist secret - }); - expect(parsed.success).toBe(true); - if (!parsed.success) { - return; - } - expect(parsed.data.enrichGroupParticipantsFromContacts).toBe(true); - }); - - it("defaults account enrichGroupParticipantsFromContacts to true", () => { - const parsed = BlueBubblesConfigSchema.safeParse({ - accounts: { - work: { - serverUrl: "http://localhost:1234", - password: "secret", // pragma: allowlist secret - }, - }, - }); - expect(parsed.success).toBe(true); - if (!parsed.success) { - return; - } - const accountConfig = ( - parsed.data as { accounts?: { work?: { enrichGroupParticipantsFromContacts?: boolean } } } - ).accounts?.work; - expect(accountConfig?.enrichGroupParticipantsFromContacts).toBe(true); - }); - - it("accepts explicit enrichGroupParticipantsFromContacts at channel and account scope", () => { - const parsed = BlueBubblesConfigSchema.safeParse({ - enrichGroupParticipantsFromContacts: true, - accounts: { - work: { - enrichGroupParticipantsFromContacts: false, - }, - }, - }); - - expect(parsed.success).toBe(true); - }); - - it("does not materialize a per-account default for replyContextApiFallback (#71820)", () => { - // Codex review: a per-account `.default(false)` would clobber a - // channel-level `replyContextApiFallback: true` during account merge, - // forcing operators to duplicate the flag under every `accounts.`. - // The schema is `.optional()` (no default) so account-level absence - // means "inherit from channel". - const parsed = BlueBubblesConfigSchema.safeParse({ - replyContextApiFallback: true, - accounts: { - work: { - serverUrl: "http://localhost:1234", - password: "secret", // pragma: allowlist secret - }, - }, - }); - expect(parsed.success).toBe(true); - if (!parsed.success) { - return; - } - const accountConfig = ( - parsed.data as { accounts?: { work?: { replyContextApiFallback?: boolean } } } - ).accounts?.work; - expect(accountConfig?.replyContextApiFallback).toBeUndefined(); - }); - - it("accepts explicit replyContextApiFallback at channel and account scope", () => { - const parsed = BlueBubblesConfigSchema.safeParse({ - replyContextApiFallback: true, - accounts: { - work: { - replyContextApiFallback: false, - }, - }, - }); - expect(parsed.success).toBe(true); - if (!parsed.success) { - return; - } - expect((parsed.data as { replyContextApiFallback?: boolean }).replyContextApiFallback).toBe( - true, - ); - expect( - (parsed.data as { accounts?: { work?: { replyContextApiFallback?: boolean } } }).accounts - ?.work?.replyContextApiFallback, - ).toBe(false); - }); - - it('rejects dmPolicy="allowlist" without channel allowFrom', () => { - const parsed = BlueBubblesConfigSchema.safeParse({ - dmPolicy: "allowlist", - }); - expect(parsed.success).toBe(false); - if (parsed.success) { - return; - } - expect(parsed.error.issues[0]?.path).toEqual(["allowFrom"]); - expect(parsed.error.issues[0]?.message).toBe( - 'channels.bluebubbles.dmPolicy="allowlist" requires channels.bluebubbles.allowFrom to contain at least one sender ID', - ); - }); - - it('rejects dmPolicy="open" without channel allowFrom wildcard', () => { - const parsed = BlueBubblesConfigSchema.safeParse({ - dmPolicy: "open", - allowFrom: ["user@example.com"], - }); - expect(parsed.success).toBe(false); - if (parsed.success) { - return; - } - expect(parsed.error.issues[0]?.path).toEqual(["allowFrom"]); - expect(parsed.error.issues[0]?.message).toBe( - 'channels.bluebubbles.dmPolicy="open" requires channels.bluebubbles.allowFrom to include "*"', - ); - }); - - it("rejects account allowlist when neither account nor channel has allowFrom", () => { - const parsed = BlueBubblesConfigSchema.safeParse({ - accounts: { - work: { - dmPolicy: "allowlist", - }, - }, - }); - expect(parsed.success).toBe(false); - if (parsed.success) { - return; - } - expect(parsed.error.issues[0]?.path).toEqual(["accounts", "work", "allowFrom"]); - expect(parsed.error.issues[0]?.message).toBe( - 'channels.bluebubbles.accounts.*.dmPolicy="allowlist" requires channels.bluebubbles.accounts.*.allowFrom (or channels.bluebubbles.allowFrom) to contain at least one sender ID', - ); - }); - - it("accepts account allowlist when channel allowFrom is inherited", () => { - const parsed = BlueBubblesConfigSchema.safeParse({ - allowFrom: ["user@example.com"], - accounts: { - work: { - dmPolicy: "allowlist", - }, - }, - }); - expect(parsed.success).toBe(true); - }); - - it("rejects account open policy when effective allowFrom has no wildcard", () => { - const parsed = BlueBubblesConfigSchema.safeParse({ - allowFrom: ["user@example.com"], - accounts: { - work: { - dmPolicy: "open", - }, - }, - }); - expect(parsed.success).toBe(false); - if (parsed.success) { - return; - } - expect(parsed.error.issues[0]?.path).toEqual(["accounts", "work", "allowFrom"]); - expect(parsed.error.issues[0]?.message).toBe( - 'channels.bluebubbles.accounts.*.dmPolicy="open" requires channels.bluebubbles.accounts.*.allowFrom (or channels.bluebubbles.allowFrom) to include "*"', - ); - }); - - it("accepts account open policy when channel allowFrom wildcard is inherited", () => { - const parsed = BlueBubblesConfigSchema.safeParse({ - allowFrom: ["*"], - accounts: { - work: { - dmPolicy: "open", - }, - }, - }); - expect(parsed.success).toBe(true); - }); -}); - -describe("bluebubbles group policy", () => { - it("uses generic channel group policy helpers", () => { - const cfg = { - channels: { - bluebubbles: { - groups: { - "chat:primary": { - requireMention: false, - tools: { deny: ["exec"] }, - }, - "*": { - requireMention: true, - tools: { allow: ["message.send"] }, - }, - }, - }, - }, - } as any; - - expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:primary" })).toBe(false); - expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:other" })).toBe(true); - expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:primary" })).toEqual({ - deny: ["exec"], - }); - expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:other" })).toEqual({ - allow: ["message.send"], - }); - }); -}); - -describe("normalizeBlueBubblesMessagingTarget", () => { - it("normalizes chat_guid targets", () => { - expect(normalizeBlueBubblesMessagingTarget("chat_guid:ABC-123")).toBe("chat_guid:ABC-123"); - }); - - it("normalizes group numeric targets to chat_id", () => { - expect(normalizeBlueBubblesMessagingTarget("group:123")).toBe("chat_id:123"); - }); - - it("strips provider prefix and normalizes handles", () => { - expect(normalizeBlueBubblesMessagingTarget("bluebubbles:imessage:User@Example.com")).toBe( - "imessage:user@example.com", - ); - }); - - it("extracts handle from DM chat_guid for cross-context matching", () => { - expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;+19257864429")).toBe( - "+19257864429", - ); - expect(normalizeBlueBubblesMessagingTarget("chat_guid:SMS;-;+15551234567")).toBe( - "+15551234567", - ); - expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;user@example.com")).toBe( - "user@example.com", - ); - }); - - it("preserves group chat_guid format", () => { - expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;+;chat123456789")).toBe( - "chat_guid:iMessage;+;chat123456789", - ); - }); - - it("normalizes raw chat_guid values", () => { - expect(normalizeBlueBubblesMessagingTarget("iMessage;+;chat660250192681427962")).toBe( - "chat_guid:iMessage;+;chat660250192681427962", - ); - expect(normalizeBlueBubblesMessagingTarget("iMessage;-;+19257864429")).toBe("+19257864429"); - }); - - it("normalizes chat pattern to chat_identifier format", () => { - expect(normalizeBlueBubblesMessagingTarget("chat660250192681427962")).toBe( - "chat_identifier:chat660250192681427962", - ); - expect(normalizeBlueBubblesMessagingTarget("chat123")).toBe("chat_identifier:chat123"); - expect(normalizeBlueBubblesMessagingTarget("Chat456789")).toBe("chat_identifier:Chat456789"); - }); - - it("normalizes UUID/hex chat identifiers", () => { - expect(normalizeBlueBubblesMessagingTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toBe( - "chat_identifier:8b9c1a10536d4d86a336ea03ab7151cc", - ); - expect(normalizeBlueBubblesMessagingTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe( - "chat_identifier:1C2D3E4F-1234-5678-9ABC-DEF012345678", - ); - }); -}); - -describe("looksLikeBlueBubblesTargetId", () => { - it("accepts chat targets", () => { - expect(looksLikeBlueBubblesTargetId("chat_guid:ABC-123")).toBe(true); - }); - - it("accepts email handles", () => { - expect(looksLikeBlueBubblesTargetId("user@example.com")).toBe(true); - }); - - it("accepts phone numbers with punctuation", () => { - expect(looksLikeBlueBubblesTargetId("+1 (555) 123-4567")).toBe(true); - }); - - it("accepts raw chat_guid values", () => { - expect(looksLikeBlueBubblesTargetId("iMessage;+;chat660250192681427962")).toBe(true); - }); - - it("accepts chat pattern as chat_id", () => { - expect(looksLikeBlueBubblesTargetId("chat660250192681427962")).toBe(true); - expect(looksLikeBlueBubblesTargetId("chat123")).toBe(true); - expect(looksLikeBlueBubblesTargetId("Chat456789")).toBe(true); - }); - - it("accepts UUID/hex chat identifiers", () => { - expect(looksLikeBlueBubblesTargetId("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(true); - expect(looksLikeBlueBubblesTargetId("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(true); - }); - - it("rejects display names", () => { - expect(looksLikeBlueBubblesTargetId("Jane Doe")).toBe(false); - }); -}); - -describe("looksLikeBlueBubblesExplicitTargetId", () => { - it("treats explicit chat targets as immediate ids", () => { - expect(looksLikeBlueBubblesExplicitTargetId("chat_guid:ABC-123")).toBe(true); - expect(looksLikeBlueBubblesExplicitTargetId("imessage:+15551234567")).toBe(true); - }); - - it("prefers directory fallback for bare handles and phone numbers", () => { - expect(looksLikeBlueBubblesExplicitTargetId("+1 (555) 123-4567")).toBe(false); - expect(looksLikeBlueBubblesExplicitTargetId("user@example.com")).toBe(false); - }); -}); - -describe("inferBlueBubblesTargetChatType", () => { - it("infers direct chat for handles and dm chat_guids", () => { - expect(inferBlueBubblesTargetChatType("+15551234567")).toBe("direct"); - expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;-;+15551234567")).toBe("direct"); - }); - - it("infers group chat for explicit group targets", () => { - expect(inferBlueBubblesTargetChatType("chat_id:123")).toBe("group"); - expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;+;chat123")).toBe("group"); - }); -}); - -describe("parseBlueBubblesTarget", () => { - it("parses chat pattern as chat_identifier", () => { - expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({ - kind: "chat_identifier", - chatIdentifier: "chat660250192681427962", - }); - expect(parseBlueBubblesTarget("chat123")).toEqual({ - kind: "chat_identifier", - chatIdentifier: "chat123", - }); - expect(parseBlueBubblesTarget("Chat456789")).toEqual({ - kind: "chat_identifier", - chatIdentifier: "Chat456789", - }); - }); - - it("parses UUID/hex chat identifiers as chat_identifier", () => { - expect(parseBlueBubblesTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({ - kind: "chat_identifier", - chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc", - }); - expect(parseBlueBubblesTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({ - kind: "chat_identifier", - chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678", - }); - }); - - it("parses explicit chat_id: prefix", () => { - expect(parseBlueBubblesTarget("chat_id:123")).toEqual({ kind: "chat_id", chatId: 123 }); - }); - - it("parses phone numbers as handles", () => { - expect(parseBlueBubblesTarget("+19257864429")).toEqual({ - kind: "handle", - to: "+19257864429", - service: "auto", - }); - }); - - it("parses raw chat_guid format", () => { - expect(parseBlueBubblesTarget("iMessage;+;chat660250192681427962")).toEqual({ - kind: "chat_guid", - chatGuid: "iMessage;+;chat660250192681427962", - }); - }); -}); - -describe("parseBlueBubblesAllowTarget", () => { - it("parses chat pattern as chat_identifier", () => { - expect(parseBlueBubblesAllowTarget("chat660250192681427962")).toEqual({ - kind: "chat_identifier", - chatIdentifier: "chat660250192681427962", - }); - expect(parseBlueBubblesAllowTarget("chat123")).toEqual({ - kind: "chat_identifier", - chatIdentifier: "chat123", - }); - }); - - it("parses UUID/hex chat identifiers as chat_identifier", () => { - expect(parseBlueBubblesAllowTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({ - kind: "chat_identifier", - chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc", - }); - expect(parseBlueBubblesAllowTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({ - kind: "chat_identifier", - chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678", - }); - }); - - it("parses explicit chat_id: prefix", () => { - expect(parseBlueBubblesAllowTarget("chat_id:456")).toEqual({ kind: "chat_id", chatId: 456 }); - }); - - it("parses phone numbers as handles", () => { - expect(parseBlueBubblesAllowTarget("+19257864429")).toEqual({ - kind: "handle", - handle: "+19257864429", - }); - }); -}); - -describe("isAllowedBlueBubblesSender", () => { - it("denies when allowFrom is empty", () => { - const allowed = isAllowedBlueBubblesSender({ - allowFrom: [], - sender: "+15551234567", - }); - expect(allowed).toBe(false); - }); - - it("allows wildcard entries", () => { - const allowed = isAllowedBlueBubblesSender({ - allowFrom: ["*"], - sender: "+15551234567", - }); - expect(allowed).toBe(true); - }); -}); diff --git a/extensions/bluebubbles/src/setup-surface.ts b/extensions/bluebubbles/src/setup-surface.ts deleted file mode 100644 index 44e5155fe8a2..000000000000 --- a/extensions/bluebubbles/src/setup-surface.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { - createAllowFromSection, - createPromptParsedAllowFromForAccount, - createStandardChannelSetupStatus, - DEFAULT_ACCOUNT_ID, - formatDocsLink, - type ChannelSetupDmPolicy, - type ChannelSetupWizard, - type OpenClawConfig, -} from "openclaw/plugin-sdk/setup"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; -import { resolveBlueBubblesAccount, resolveDefaultBlueBubblesAccountId } from "./accounts.js"; -import { applyBlueBubblesConnectionConfig } from "./config-apply.js"; -import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js"; -import { - blueBubblesSetupAdapter, - setBlueBubblesAllowFrom, - setBlueBubblesDmPolicy, -} from "./setup-core.js"; -import { parseBlueBubblesAllowTarget } from "./targets.js"; -import { normalizeBlueBubblesServerUrl } from "./types.js"; -import { DEFAULT_WEBHOOK_PATH } from "./webhook-shared.js"; - -const channel = "bluebubbles" as const; -const CONFIGURE_CUSTOM_WEBHOOK_FLAG = "__bluebubblesConfigureCustomWebhookPath"; - -function parseBlueBubblesAllowFromInput(raw: string): string[] { - return raw - .split(/[\n,]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); -} - -function validateBlueBubblesAllowFromEntry(value: string): string | null { - try { - if (value === "*") { - return value; - } - const parsed = parseBlueBubblesAllowTarget(value); - if (parsed.kind === "handle" && !parsed.handle) { - return null; - } - return normalizeOptionalString(value) ?? null; - } catch { - return null; - } -} - -const promptBlueBubblesAllowFrom = createPromptParsedAllowFromForAccount({ - defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg), - noteTitle: "BlueBubbles allowlist", - noteLines: [ - "Allowlist BlueBubbles DMs by handle or chat target.", - "Examples:", - "- +15555550123", - "- user@example.com", - "- chat_id:123", - "- chat_guid:iMessage;-;+15555550123", - "Multiple entries: comma- or newline-separated.", - `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`, - ], - message: "BlueBubbles allowFrom (handle or chat_id)", - placeholder: "+15555550123, user@example.com, chat_id:123", - parseEntries: (raw) => { - const entries = parseBlueBubblesAllowFromInput(raw); - for (const entry of entries) { - if (!validateBlueBubblesAllowFromEntry(entry)) { - return { entries: [], error: `Invalid entry: ${entry}` }; - } - } - return { entries }; - }, - getExistingAllowFrom: ({ cfg, accountId }) => - resolveBlueBubblesAccount({ cfg, accountId }).config.allowFrom ?? [], - applyAllowFrom: ({ cfg, accountId, allowFrom }) => - setBlueBubblesAllowFrom(cfg, accountId, allowFrom), -}); - -function validateBlueBubblesServerUrlInput(value: unknown): string | undefined { - const trimmed = normalizeOptionalString(value) ?? ""; - if (!trimmed) { - return "Required"; - } - try { - const normalized = normalizeBlueBubblesServerUrl(trimmed); - if (!URL.canParse(normalized)) { - return "Invalid URL format"; - } - return undefined; - } catch { - return "Invalid URL format"; - } -} - -function applyBlueBubblesSetupPatch( - cfg: OpenClawConfig, - accountId: string, - patch: { - serverUrl?: string; - password?: unknown; - webhookPath?: string; - }, -): OpenClawConfig { - return applyBlueBubblesConnectionConfig({ - cfg, - accountId, - patch, - onlyDefinedFields: true, - accountEnabled: "preserve-or-true", - }); -} - -function validateBlueBubblesWebhookPath(value: string): string | undefined { - const trimmed = value.trim(); - if (!trimmed) { - return "Required"; - } - if (!trimmed.startsWith("/")) { - return "Path must start with /"; - } - return undefined; -} - -const dmPolicy: ChannelSetupDmPolicy = { - label: "BlueBubbles", - channel, - policyKey: "channels.bluebubbles.dmPolicy", - allowFromKey: "channels.bluebubbles.allowFrom", - resolveConfigKeys: (cfg, accountId) => - (accountId ?? resolveDefaultBlueBubblesAccountId(cfg)) !== DEFAULT_ACCOUNT_ID - ? { - policyKey: `channels.bluebubbles.accounts.${accountId ?? resolveDefaultBlueBubblesAccountId(cfg)}.dmPolicy`, - allowFromKey: `channels.bluebubbles.accounts.${accountId ?? resolveDefaultBlueBubblesAccountId(cfg)}.allowFrom`, - } - : { - policyKey: "channels.bluebubbles.dmPolicy", - allowFromKey: "channels.bluebubbles.allowFrom", - }, - getCurrent: (cfg, accountId) => - resolveBlueBubblesAccount({ - cfg, - accountId: accountId ?? resolveDefaultBlueBubblesAccountId(cfg), - }).config.dmPolicy ?? "pairing", - setPolicy: (cfg, policy, accountId) => - setBlueBubblesDmPolicy(cfg, accountId ?? resolveDefaultBlueBubblesAccountId(cfg), policy), - promptAllowFrom: promptBlueBubblesAllowFrom, -}; - -export const blueBubblesSetupWizard: ChannelSetupWizard = { - channel, - stepOrder: "text-first", - status: { - ...createStandardChannelSetupStatus({ - channelLabel: "BlueBubbles", - configuredLabel: "configured", - unconfiguredLabel: "needs setup", - configuredHint: "configured", - unconfiguredHint: "iMessage via BlueBubbles app", - configuredScore: 1, - unconfiguredScore: 0, - includeStatusLine: true, - resolveConfigured: ({ cfg, accountId }) => - resolveBlueBubblesAccount({ cfg, accountId }).configured, - }), - resolveSelectionHint: ({ configured }) => - configured ? "configured" : "iMessage via BlueBubbles app", - }, - prepare: async ({ cfg, accountId, prompter, credentialValues }) => { - const existingWebhookPath = normalizeOptionalString( - resolveBlueBubblesAccount({ cfg, accountId }).config.webhookPath, - ); - const wantsCustomWebhook = await prompter.confirm({ - message: `Configure a custom webhook path? (default: ${DEFAULT_WEBHOOK_PATH})`, - initialValue: Boolean(existingWebhookPath && existingWebhookPath !== DEFAULT_WEBHOOK_PATH), - }); - return { - cfg: wantsCustomWebhook - ? cfg - : applyBlueBubblesSetupPatch(cfg, accountId, { webhookPath: DEFAULT_WEBHOOK_PATH }), - credentialValues: { - ...credentialValues, - [CONFIGURE_CUSTOM_WEBHOOK_FLAG]: wantsCustomWebhook ? "1" : "0", - }, - }; - }, - credentials: [ - { - inputKey: "password", - providerHint: channel, - credentialLabel: "server password", - helpTitle: "BlueBubbles password", - helpLines: [ - "Enter the BlueBubbles server password.", - "Find this in the BlueBubbles Server app under Settings.", - ], - envPrompt: "", - keepPrompt: "BlueBubbles password already set. Keep it?", - inputPrompt: "BlueBubbles password", - inspect: ({ cfg, accountId }) => { - const existingPassword = resolveBlueBubblesAccount({ cfg, accountId }).config.password; - return { - accountConfigured: resolveBlueBubblesAccount({ cfg, accountId }).configured, - hasConfiguredValue: hasConfiguredSecretInput(existingPassword), - resolvedValue: normalizeSecretInputString(existingPassword) ?? undefined, - }; - }, - applySet: async ({ cfg, accountId, value }) => - applyBlueBubblesSetupPatch(cfg, accountId, { - password: value, - }), - }, - ], - textInputs: [ - { - inputKey: "httpUrl", - message: "BlueBubbles server URL", - placeholder: "http://192.168.1.100:1234", - helpTitle: "BlueBubbles server URL", - helpLines: [ - "Enter the BlueBubbles server URL (e.g., http://192.168.1.100:1234).", - "Find this in the BlueBubbles Server app under Connection.", - `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`, - ], - currentValue: ({ cfg, accountId }) => - normalizeOptionalString(resolveBlueBubblesAccount({ cfg, accountId }).config.serverUrl), - validate: ({ value }) => validateBlueBubblesServerUrlInput(value), - normalizeValue: ({ value }) => value.trim(), - applySet: async ({ cfg, accountId, value }) => - applyBlueBubblesSetupPatch(cfg, accountId, { - serverUrl: value, - }), - }, - { - inputKey: "webhookPath", - message: "Webhook path", - placeholder: DEFAULT_WEBHOOK_PATH, - currentValue: ({ cfg, accountId }) => { - const value = normalizeOptionalString( - resolveBlueBubblesAccount({ cfg, accountId }).config.webhookPath, - ); - return value && value !== DEFAULT_WEBHOOK_PATH ? value : undefined; - }, - shouldPrompt: ({ credentialValues }) => - credentialValues[CONFIGURE_CUSTOM_WEBHOOK_FLAG] === "1", - validate: ({ value }) => validateBlueBubblesWebhookPath(value), - normalizeValue: ({ value }) => value.trim(), - applySet: async ({ cfg, accountId, value }) => - applyBlueBubblesSetupPatch(cfg, accountId, { - webhookPath: value, - }), - }, - ], - completionNote: { - title: "BlueBubbles next steps", - lines: [ - "Configure the webhook URL in BlueBubbles Server:", - "1. Open BlueBubbles Server -> Settings -> Webhooks", - "2. Add your OpenClaw gateway URL + webhook path", - ` Example: https://your-gateway-host:3000${DEFAULT_WEBHOOK_PATH}`, - "3. Enable the webhook and save", - "", - `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`, - ], - }, - dmPolicy, - allowFrom: createAllowFromSection({ - helpTitle: "BlueBubbles allowlist", - helpLines: [ - "Allowlist BlueBubbles DMs by handle or chat target.", - "Examples:", - "- +15555550123", - "- user@example.com", - "- chat_id:123", - "- chat_guid:iMessage;-;+15555550123", - "Multiple entries: comma- or newline-separated.", - `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`, - ], - message: "BlueBubbles allowFrom (handle or chat_id)", - placeholder: "+15555550123, user@example.com, chat_id:123", - invalidWithoutCredentialNote: - "Use a BlueBubbles handle or chat target like +15555550123 or chat_id:123.", - parseInputs: parseBlueBubblesAllowFromInput, - parseId: (raw) => validateBlueBubblesAllowFromEntry(raw), - apply: async ({ cfg, accountId, allowFrom }) => - setBlueBubblesAllowFrom(cfg, accountId, allowFrom), - }), - disable: (cfg) => ({ - ...cfg, - channels: { - ...cfg.channels, - bluebubbles: { - ...cfg.channels?.bluebubbles, - enabled: false, - }, - }, - }), -}; - -export { blueBubblesSetupAdapter }; diff --git a/extensions/bluebubbles/src/status-issues.test.ts b/extensions/bluebubbles/src/status-issues.test.ts deleted file mode 100644 index 1d1ed69c837c..000000000000 --- a/extensions/bluebubbles/src/status-issues.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { collectBlueBubblesStatusIssues } from "./status-issues.js"; - -describe("collectBlueBubblesStatusIssues", () => { - it("reports unconfigured enabled accounts", () => { - const issues = collectBlueBubblesStatusIssues([ - { - accountId: "default", - enabled: true, - configured: false, - }, - ]); - - expect(issues).toEqual([ - expect.objectContaining({ - channel: "bluebubbles", - accountId: "default", - kind: "config", - }), - ]); - }); - - it("reports probe failure and runtime error for configured running accounts", () => { - const issues = collectBlueBubblesStatusIssues([ - { - accountId: "work", - enabled: true, - configured: true, - running: true, - lastError: "timeout", - probe: { - ok: false, - status: 503, - }, - }, - ]); - - expect(issues).toHaveLength(2); - expect(issues[0]).toEqual( - expect.objectContaining({ - channel: "bluebubbles", - accountId: "work", - kind: "runtime", - }), - ); - expect(issues[1]).toEqual( - expect.objectContaining({ - channel: "bluebubbles", - accountId: "work", - kind: "runtime", - message: "Channel error: timeout", - }), - ); - }); -}); diff --git a/extensions/bluebubbles/src/status-issues.ts b/extensions/bluebubbles/src/status-issues.ts deleted file mode 100644 index 5333b2a894e1..000000000000 --- a/extensions/bluebubbles/src/status-issues.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract"; -import { collectIssuesForEnabledAccounts } from "openclaw/plugin-sdk/status-helpers"; -import { asRecord } from "./monitor-normalize.js"; - -type BlueBubblesAccountStatus = { - accountId?: unknown; - enabled?: unknown; - configured?: unknown; - running?: unknown; - baseUrl?: unknown; - lastError?: unknown; - probe?: unknown; -}; - -type BlueBubblesProbeResult = { - ok?: boolean; - status?: number | null; - error?: string | null; -}; - -function asString(value: unknown): string | null { - return typeof value === "string" && value.length > 0 ? value : null; -} - -function readBlueBubblesAccountStatus( - value: ChannelAccountSnapshot, -): BlueBubblesAccountStatus | null { - const record = asRecord(value); - if (!record) { - return null; - } - return { - accountId: record.accountId, - enabled: record.enabled, - configured: record.configured, - running: record.running, - baseUrl: record.baseUrl, - lastError: record.lastError, - probe: record.probe, - }; -} - -function readBlueBubblesProbeResult(value: unknown): BlueBubblesProbeResult | null { - const record = asRecord(value); - if (!record) { - return null; - } - return { - ok: typeof record.ok === "boolean" ? record.ok : undefined, - status: typeof record.status === "number" ? record.status : null, - error: asString(record.error) ?? null, - }; -} - -export function collectBlueBubblesStatusIssues(accounts: ChannelAccountSnapshot[]) { - return collectIssuesForEnabledAccounts({ - accounts, - readAccount: readBlueBubblesAccountStatus, - collectIssues: ({ account, accountId, issues }) => { - const configured = account.configured === true; - const running = account.running === true; - const lastError = asString(account.lastError); - const probe = readBlueBubblesProbeResult(account.probe); - - if (!configured) { - issues.push({ - channel: "bluebubbles", - accountId, - kind: "config", - message: "Not configured (missing serverUrl or password).", - fix: "Run: openclaw channels add bluebubbles --http-url --password ", - }); - return; - } - - if (probe && probe.ok === false) { - const errorDetail = probe.error - ? `: ${probe.error}` - : probe.status - ? ` (HTTP ${probe.status})` - : ""; - issues.push({ - channel: "bluebubbles", - accountId, - kind: "runtime", - message: `BlueBubbles server unreachable${errorDetail}`, - fix: "Check that the BlueBubbles server is running and accessible. Verify serverUrl and password in your config.", - }); - } - - if (running && lastError) { - issues.push({ - channel: "bluebubbles", - accountId, - kind: "runtime", - message: `Channel error: ${lastError}`, - fix: "Check gateway logs for details. If the webhook is failing, verify the webhook URL is configured in BlueBubbles server settings.", - }); - } - }, - }); -} diff --git a/extensions/bluebubbles/src/targets.ts b/extensions/bluebubbles/src/targets.ts deleted file mode 100644 index 7ec5233b2b60..000000000000 --- a/extensions/bluebubbles/src/targets.ts +++ /dev/null @@ -1,477 +0,0 @@ -import { isAllowedParsedChatSender } from "openclaw/plugin-sdk/allow-from"; -import { - parseChatAllowTargetPrefixes, - parseChatTargetPrefixesOrThrow, - type ParsedChatTarget, - resolveServicePrefixedAllowTarget, - resolveServicePrefixedTarget, -} from "openclaw/plugin-sdk/channel-targets"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, -} from "openclaw/plugin-sdk/text-runtime"; - -type BlueBubblesService = "imessage" | "sms" | "auto"; - -type BlueBubblesTarget = - | { kind: "chat_id"; chatId: number } - | { kind: "chat_guid"; chatGuid: string } - | { kind: "chat_identifier"; chatIdentifier: string } - | { kind: "handle"; to: string; service: BlueBubblesService }; - -type BlueBubblesAllowTarget = ParsedChatTarget | { kind: "handle"; handle: string }; - -const CHAT_ID_PREFIXES = ["chat_id:", "chatid:", "chat:"]; -const CHAT_GUID_PREFIXES = ["chat_guid:", "chatguid:", "guid:"]; -const CHAT_IDENTIFIER_PREFIXES = ["chat_identifier:", "chatidentifier:", "chatident:"]; -const SERVICE_PREFIXES: Array<{ prefix: string; service: BlueBubblesService }> = [ - { prefix: "imessage:", service: "imessage" }, - { prefix: "sms:", service: "sms" }, - { prefix: "auto:", service: "auto" }, -]; -const CHAT_IDENTIFIER_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -const CHAT_IDENTIFIER_HEX_RE = /^[0-9a-f]{24,64}$/i; - -function parseRawChatGuid(value: string): string | null { - const trimmed = normalizeOptionalString(value); - if (!trimmed) { - return null; - } - const parts = trimmed.split(";"); - if (parts.length !== 3) { - return null; - } - const service = normalizeOptionalString(parts[0]); - const separator = normalizeOptionalString(parts[1]); - const identifier = normalizeOptionalString(parts[2]); - if (!service || !identifier) { - return null; - } - if (separator !== "+" && separator !== "-") { - return null; - } - return `${service};${separator};${identifier}`; -} - -function stripPrefix(value: string, prefix: string): string { - return value.slice(prefix.length).trim(); -} - -function stripBlueBubblesPrefix(value: string): string { - const trimmed = normalizeOptionalString(value) ?? ""; - if (!trimmed) { - return ""; - } - if (!normalizeLowercaseStringOrEmpty(trimmed).startsWith("bluebubbles:")) { - return trimmed; - } - return trimmed.slice("bluebubbles:".length).trim(); -} - -function looksLikeRawChatIdentifier(value: string): boolean { - const trimmed = normalizeOptionalString(value); - if (!trimmed) { - return false; - } - if (/^chat\d+$/i.test(trimmed)) { - return true; - } - return CHAT_IDENTIFIER_UUID_RE.test(trimmed) || CHAT_IDENTIFIER_HEX_RE.test(trimmed); -} - -function parseGroupTarget(params: { - trimmed: string; - lower: string; - requireValue: boolean; -}): { kind: "chat_id"; chatId: number } | { kind: "chat_guid"; chatGuid: string } | null { - if (!params.lower.startsWith("group:")) { - return null; - } - const value = stripPrefix(params.trimmed, "group:"); - const chatId = Number.parseInt(value, 10); - if (Number.isFinite(chatId)) { - return { kind: "chat_id", chatId }; - } - if (value) { - return { kind: "chat_guid", chatGuid: value }; - } - if (params.requireValue) { - throw new Error("group target is required"); - } - return null; -} - -function parseRawChatIdentifierTarget( - trimmed: string, -): { kind: "chat_identifier"; chatIdentifier: string } | null { - if (/^chat\d+$/i.test(trimmed)) { - return { kind: "chat_identifier", chatIdentifier: trimmed }; - } - if (looksLikeRawChatIdentifier(trimmed)) { - return { kind: "chat_identifier", chatIdentifier: trimmed }; - } - return null; -} - -export function normalizeBlueBubblesHandle(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed) { - return ""; - } - const lowered = normalizeLowercaseStringOrEmpty(trimmed); - if (lowered.startsWith("imessage:")) { - return normalizeBlueBubblesHandle(trimmed.slice(9)); - } - if (lowered.startsWith("sms:")) { - return normalizeBlueBubblesHandle(trimmed.slice(4)); - } - if (lowered.startsWith("auto:")) { - return normalizeBlueBubblesHandle(trimmed.slice(5)); - } - if (trimmed.includes("@")) { - return normalizeLowercaseStringOrEmpty(trimmed); - } - return trimmed.replace(/\s+/g, ""); -} - -/** - * Extracts the handle from a chat_guid if it's a DM (1:1 chat). - * BlueBubbles chat_guid format for DM: "service;-;handle" (e.g., "iMessage;-;+19257864429") - * Group chat format: "service;+;groupId" (has "+" instead of "-") - */ -export function extractHandleFromChatGuid(chatGuid: string): string | null { - const parts = chatGuid.split(";"); - // DM format: service;-;handle (3 parts, middle is "-") - if (parts.length === 3 && parts[1] === "-") { - const handle = normalizeOptionalString(parts[2]); - if (handle) { - return normalizeBlueBubblesHandle(handle); - } - } - return null; -} - -export function normalizeBlueBubblesMessagingTarget(raw: string): string | undefined { - let trimmed = raw.trim(); - if (!trimmed) { - return undefined; - } - trimmed = stripBlueBubblesPrefix(trimmed); - if (!trimmed) { - return undefined; - } - try { - const parsed = parseBlueBubblesTarget(trimmed); - if (parsed.kind === "chat_id") { - return `chat_id:${parsed.chatId}`; - } - if (parsed.kind === "chat_guid") { - // For DM chat_guids, normalize to just the handle for easier comparison. - // This allows "chat_guid:iMessage;-;+1234567890" to match "+1234567890". - const handle = extractHandleFromChatGuid(parsed.chatGuid); - if (handle) { - return handle; - } - // For group chats or unrecognized formats, keep the full chat_guid - return `chat_guid:${parsed.chatGuid}`; - } - if (parsed.kind === "chat_identifier") { - return `chat_identifier:${parsed.chatIdentifier}`; - } - const handle = normalizeBlueBubblesHandle(parsed.to); - if (!handle) { - return undefined; - } - return parsed.service === "auto" ? handle : `${parsed.service}:${handle}`; - } catch { - return trimmed; - } -} - -export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string): boolean { - const trimmed = raw.trim(); - if (!trimmed) { - return false; - } - const candidate = stripBlueBubblesPrefix(trimmed); - if (!candidate) { - return false; - } - if (parseRawChatGuid(candidate)) { - return true; - } - const lowered = normalizeLowercaseStringOrEmpty(candidate); - if (/^(imessage|sms|auto):/.test(lowered)) { - return true; - } - if ( - /^(chat_id|chatid|chat|chat_guid|chatguid|guid|chat_identifier|chatidentifier|chatident|group):/.test( - lowered, - ) - ) { - return true; - } - // Recognize chat patterns (e.g., "chat660250192681427962") as chat IDs - if (/^chat\d+$/i.test(candidate)) { - return true; - } - if (looksLikeRawChatIdentifier(candidate)) { - return true; - } - if (candidate.includes("@")) { - return true; - } - const digitsOnly = candidate.replace(/[\s().-]/g, ""); - if (/^\+?\d{3,}$/.test(digitsOnly)) { - return true; - } - if (normalized) { - const normalizedTrimmed = normalizeOptionalString(normalized); - if (!normalizedTrimmed) { - return false; - } - const normalizedLower = normalizeLowercaseStringOrEmpty(normalizedTrimmed); - if ( - /^(imessage|sms|auto):/.test(normalizedLower) || - /^(chat_id|chat_guid|chat_identifier):/.test(normalizedLower) - ) { - return true; - } - } - return false; -} - -export function looksLikeBlueBubblesExplicitTargetId(raw: string, normalized?: string): boolean { - const trimmed = raw.trim(); - if (!trimmed) { - return false; - } - const candidate = stripBlueBubblesPrefix(trimmed); - if (!candidate) { - return false; - } - const lowered = normalizeLowercaseStringOrEmpty(candidate); - if (/^(imessage|sms|auto):/.test(lowered)) { - return true; - } - if ( - /^(chat_id|chatid|chat|chat_guid|chatguid|guid|chat_identifier|chatidentifier|chatident|group):/.test( - lowered, - ) - ) { - return true; - } - if (parseRawChatGuid(candidate) || looksLikeRawChatIdentifier(candidate)) { - return true; - } - if (normalized) { - const normalizedTrimmed = normalized.trim(); - if (!normalizedTrimmed) { - return false; - } - const normalizedLower = normalizeLowercaseStringOrEmpty(normalizedTrimmed); - if ( - /^(imessage|sms|auto):/.test(normalizedLower) || - /^(chat_id|chat_guid|chat_identifier):/.test(normalizedLower) - ) { - return true; - } - } - return false; -} - -export function inferBlueBubblesTargetChatType(raw: string): "direct" | "group" | undefined { - try { - const parsed = parseBlueBubblesTarget(raw); - if (parsed.kind === "handle") { - return "direct"; - } - if (parsed.kind === "chat_guid") { - return parsed.chatGuid.includes(";+;") ? "group" : "direct"; - } - if (parsed.kind === "chat_id" || parsed.kind === "chat_identifier") { - return "group"; - } - } catch { - return undefined; - } - return undefined; -} - -export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget { - const trimmed = stripBlueBubblesPrefix(raw); - if (!trimmed) { - throw new Error("BlueBubbles target is required"); - } - const lower = normalizeLowercaseStringOrEmpty(trimmed); - - const servicePrefixed = resolveServicePrefixedTarget({ - trimmed, - lower, - servicePrefixes: SERVICE_PREFIXES, - isChatTarget: (remainderLower) => - CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) || - CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) || - CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)) || - remainderLower.startsWith("group:"), - parseTarget: parseBlueBubblesTarget, - }); - if (servicePrefixed) { - return servicePrefixed; - } - - const chatTarget = parseChatTargetPrefixesOrThrow({ - trimmed, - lower, - chatIdPrefixes: CHAT_ID_PREFIXES, - chatGuidPrefixes: CHAT_GUID_PREFIXES, - chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES, - }); - if (chatTarget) { - return chatTarget; - } - - const groupTarget = parseGroupTarget({ trimmed, lower, requireValue: true }); - if (groupTarget) { - return groupTarget; - } - - const rawChatGuid = parseRawChatGuid(trimmed); - if (rawChatGuid) { - return { kind: "chat_guid", chatGuid: rawChatGuid }; - } - - const rawChatIdentifierTarget = parseRawChatIdentifierTarget(trimmed); - if (rawChatIdentifierTarget) { - return rawChatIdentifierTarget; - } - - return { kind: "handle", to: trimmed, service: "auto" }; -} - -export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget { - const trimmed = normalizeOptionalString(raw) ?? ""; - if (!trimmed) { - return { kind: "handle", handle: "" }; - } - const lower = normalizeLowercaseStringOrEmpty(trimmed); - - const servicePrefixed = resolveServicePrefixedAllowTarget({ - trimmed, - lower, - servicePrefixes: SERVICE_PREFIXES, - parseAllowTarget: parseBlueBubblesAllowTarget, - }); - if (servicePrefixed) { - return servicePrefixed; - } - - const chatTarget = parseChatAllowTargetPrefixes({ - trimmed, - lower, - chatIdPrefixes: CHAT_ID_PREFIXES, - chatGuidPrefixes: CHAT_GUID_PREFIXES, - chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES, - }); - if (chatTarget) { - return chatTarget; - } - - const groupTarget = parseGroupTarget({ trimmed, lower, requireValue: false }); - if (groupTarget) { - return groupTarget; - } - - const rawChatIdentifierTarget = parseRawChatIdentifierTarget(trimmed); - if (rawChatIdentifierTarget) { - return rawChatIdentifierTarget; - } - - return { kind: "handle", handle: normalizeBlueBubblesHandle(trimmed) }; -} - -export function isAllowedBlueBubblesSender(params: { - allowFrom: Array; - sender: string; - chatId?: number | null; - chatGuid?: string | null; - chatIdentifier?: string | null; -}): boolean { - return isAllowedParsedChatSender({ - allowFrom: params.allowFrom, - sender: params.sender, - chatId: params.chatId, - chatGuid: params.chatGuid, - chatIdentifier: params.chatIdentifier, - normalizeSender: normalizeBlueBubblesHandle, - parseAllowTarget: parseBlueBubblesAllowTarget, - }); -} - -export function formatBlueBubblesChatTarget(params: { - chatId?: number | null; - chatGuid?: string | null; - chatIdentifier?: string | null; -}): string { - if (params.chatId && Number.isFinite(params.chatId)) { - return `chat_id:${params.chatId}`; - } - const guid = normalizeOptionalString(params.chatGuid); - if (guid) { - return `chat_guid:${guid}`; - } - const identifier = normalizeOptionalString(params.chatIdentifier); - if (identifier) { - return `chat_identifier:${identifier}`; - } - return ""; -} - -/** - * Derive a chat context ({chatGuid, chatIdentifier, chatId}) from a raw - * BlueBubbles target string such as `chat_guid:iMessage;+;chat123`, - * `chat_id:42`, `imessage:+15551234567`, or a bare handle. Returns an empty - * object for unparseable input. - * - * Used by short-ID message resolution to constrain short IDs to the chat the - * caller is acting on, preventing a short ID allocated for a message in one - * chat from silently pointing at a different chat on a later tool call. - */ -export function buildBlueBubblesChatContextFromTarget(raw: string | undefined | null): { - chatGuid?: string; - chatIdentifier?: string; - chatId?: number; -} { - const trimmed = normalizeOptionalString(raw); - if (!trimmed) { - return {}; - } - try { - const parsed = parseBlueBubblesTarget(trimmed); - if (parsed.kind === "chat_guid") { - return { chatGuid: parsed.chatGuid }; - } - if (parsed.kind === "chat_identifier") { - return { chatIdentifier: parsed.chatIdentifier }; - } - if (parsed.kind === "chat_id") { - return { chatId: parsed.chatId }; - } - if (parsed.kind === "handle") { - // BlueBubbles chat records store DM handles in the third component of - // their chatGuid (service;-;address), and `chatIdentifier` on a chat - // record is typically the same address. Treat a handle target as a - // chatIdentifier hint; it disambiguates DM↔DM and DM↔group mixes. - // Normalize the handle (strip service prefix / whitespace / lowercase - // emails) so the comparison matches what the send path resolves to - // and what inbound webhooks write into the reply cache; otherwise - // formats like `imessage:(555) 123-4567` or mixed-case email handles - // would compare unequal against their normalized cached form and - // legitimate same-chat short IDs would be rejected as cross-chat. - return { chatIdentifier: normalizeBlueBubblesHandle(parsed.to) }; - } - return {}; - } catch { - return {}; - } -} diff --git a/extensions/bluebubbles/src/test-harness.ts b/extensions/bluebubbles/src/test-harness.ts deleted file mode 100644 index 08de0d01c2dc..000000000000 --- a/extensions/bluebubbles/src/test-harness.ts +++ /dev/null @@ -1,155 +0,0 @@ -import type { Mock } from "vitest"; -import { afterEach, beforeEach, vi } from "vitest"; -import { - normalizeBlueBubblesAccountsMap, - normalizeBlueBubblesPrivateNetworkAliases, - resolveBlueBubblesEffectiveAllowPrivateNetworkFromConfig, - resolveBlueBubblesPrivateNetworkConfigValue as resolveBlueBubblesPrivateNetworkConfigValueFromConfig, -} from "./accounts-normalization.js"; -import { _setFetchGuardForTesting } from "./types.js"; - -export const BLUE_BUBBLES_PRIVATE_API_STATUS = { - enabled: true, - disabled: false, - unknown: null, -} as const; - -type BlueBubblesPrivateApiStatusMock = { - mockReturnValue: (value: boolean | null) => unknown; - mockReturnValueOnce: (value: boolean | null) => unknown; -}; - -export function mockBlueBubblesPrivateApiStatus( - mock: Pick, - value: boolean | null, -) { - mock.mockReturnValue(value); -} - -export function mockBlueBubblesPrivateApiStatusOnce( - mock: Pick, - value: boolean | null, -) { - mock.mockReturnValueOnce(value); -} - -function resolveBlueBubblesAccountFromConfig(params: { - cfg?: { channels?: { bluebubbles?: Record } }; - accountId?: string; -}) { - const baseConfig = - normalizeBlueBubblesPrivateNetworkAliases(params.cfg?.channels?.bluebubbles ?? {}) ?? {}; - const accounts = normalizeBlueBubblesAccountsMap( - baseConfig.accounts as Record | undefined> | undefined, - ); - const accountId = params.accountId ?? "default"; - const accountConfig = - normalizeBlueBubblesPrivateNetworkAliases(accounts?.[accountId] ?? {}) ?? {}; - const config: Record = { - ...baseConfig, - ...accountConfig, - network: - typeof baseConfig.network === "object" && - baseConfig.network && - !Array.isArray(baseConfig.network) && - typeof accountConfig.network === "object" && - accountConfig.network && - !Array.isArray(accountConfig.network) - ? { - ...(baseConfig.network as Record), - ...(accountConfig.network as Record), - } - : (accountConfig.network ?? baseConfig.network), - }; - return { - accountId, - enabled: config.enabled !== false, - configured: Boolean(config.serverUrl && config.password), - config, - }; -} - -export function createBlueBubblesAccountsMockModule() { - return { - resolveBlueBubblesAccount: vi.fn(resolveBlueBubblesAccountFromConfig), - resolveBlueBubblesEffectiveAllowPrivateNetwork: vi.fn( - resolveBlueBubblesEffectiveAllowPrivateNetworkFromConfig, - ), - resolveBlueBubblesPrivateNetworkConfigValue: vi.fn( - resolveBlueBubblesPrivateNetworkConfigValueFromConfig, - ), - }; -} - -type BlueBubblesProbeMockModule = { - fetchBlueBubblesServerInfo: Mock<() => Promise | null>>; - getCachedBlueBubblesPrivateApiStatus: Mock<() => boolean | null>; - isBlueBubblesPrivateApiStatusEnabled: Mock<(status: boolean | null) => boolean>; - isMacOS26OrHigher: Mock<(accountId?: string) => boolean>; -}; - -export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule { - return { - fetchBlueBubblesServerInfo: vi.fn().mockResolvedValue(null), - getCachedBlueBubblesPrivateApiStatus: vi - .fn() - .mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown), - isBlueBubblesPrivateApiStatusEnabled: vi.fn((status: boolean | null) => status === true), - isMacOS26OrHigher: vi.fn().mockReturnValue(false), - }; -} - -export function installBlueBubblesFetchTestHooks(params: { - mockFetch: ReturnType; - privateApiStatusMock: { - mockReset?: () => unknown; - mockClear?: () => unknown; - mockReturnValue: (value: boolean | null) => unknown; - }; -}) { - const setFetchGuardPassthrough = createBlueBubblesFetchGuardPassthroughInstaller(); - beforeEach(() => { - vi.stubGlobal("fetch", params.mockFetch); - // Replace the SSRF guard with a passthrough that delegates to the mocked global.fetch, - // wrapping the result in a real Response so callers can call .arrayBuffer() on it. - setFetchGuardPassthrough(); - params.mockFetch.mockReset(); - params.privateApiStatusMock.mockReset?.(); - params.privateApiStatusMock.mockClear?.(); - params.privateApiStatusMock.mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown); - }); - - afterEach(() => { - _setFetchGuardForTesting(null); - vi.unstubAllGlobals(); - }); -} - -export function createBlueBubblesFetchGuardPassthroughInstaller() { - return (capturePolicy?: (policy: unknown) => void) => { - _setFetchGuardForTesting(async (params) => { - capturePolicy?.(params.policy); - const raw = await globalThis.fetch(params.url, params.init); - let body: ArrayBuffer; - if (typeof raw.arrayBuffer === "function") { - body = await raw.arrayBuffer(); - } else { - const text = - typeof (raw as { text?: () => Promise }).text === "function" - ? await (raw as { text: () => Promise }).text() - : typeof (raw as { json?: () => Promise }).json === "function" - ? JSON.stringify(await (raw as { json: () => Promise }).json()) - : ""; - body = new TextEncoder().encode(text).buffer; - } - return { - response: new Response(body, { - status: (raw as { status?: number }).status ?? 200, - headers: (raw as { headers?: HeadersInit }).headers, - }), - release: async () => {}, - finalUrl: params.url, - }; - }); - }; -} diff --git a/extensions/bluebubbles/src/test-helpers.ts b/extensions/bluebubbles/src/test-helpers.ts deleted file mode 100644 index 1f0035ab75b3..000000000000 --- a/extensions/bluebubbles/src/test-helpers.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { vi } from "vitest"; -import type { PluginRuntime } from "./runtime-api.js"; - -type FetchRemoteMediaParams = { - url: string; - maxBytes?: number; - ssrfPolicy?: unknown; - fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; -}; - -type FetchRemoteMediaHttpErrorParams = { - response: Response; - url: string; -}; - -export function createBlueBubblesFetchRemoteMediaMock(options: { - createHttpError: (params: FetchRemoteMediaHttpErrorParams) => Error | Promise; -}) { - return vi.fn(async (params: FetchRemoteMediaParams) => { - const fetchFn = params.fetchImpl ?? fetch; - const res = await fetchFn(params.url); - if (!res.ok) { - throw await options.createHttpError({ response: res, url: params.url }); - } - const buffer = Buffer.from(await res.arrayBuffer()); - if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) { - const error = new Error(`payload exceeds maxBytes ${params.maxBytes}`) as Error & { - code?: string; - }; - error.code = "max_bytes"; - throw error; - } - return { - buffer, - contentType: res.headers.get("content-type") ?? undefined, - fileName: undefined, - }; - }); -} - -export function createBlueBubblesRuntimeStub( - fetchRemoteMediaMock: ReturnType, -) { - return { - channel: { - media: { - fetchRemoteMedia: - fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"], - }, - }, - } as unknown as PluginRuntime; -} diff --git a/extensions/bluebubbles/src/test-mocks.ts b/extensions/bluebubbles/src/test-mocks.ts deleted file mode 100644 index d0a4801663dc..000000000000 --- a/extensions/bluebubbles/src/test-mocks.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { vi } from "vitest"; - -vi.mock("./accounts.js", async () => { - const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js"); - return createBlueBubblesAccountsMockModule(); -}); - -vi.mock("./probe.js", async () => { - const { createBlueBubblesProbeMockModule } = await import("./test-harness.js"); - return createBlueBubblesProbeMockModule(); -}); diff --git a/extensions/bluebubbles/src/test-support/monitor-test-support.ts b/extensions/bluebubbles/src/test-support/monitor-test-support.ts deleted file mode 100644 index dfd480657fca..000000000000 --- a/extensions/bluebubbles/src/test-support/monitor-test-support.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers"; -import type { PluginRuntime } from "openclaw/plugin-sdk/core"; -import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history"; -import { vi } from "vitest"; -import { _resetBlueBubblesInboundDedupForTest } from "../inbound-dedupe.js"; -import { - _resetBlueBubblesShortIdState, - clearBlueBubblesWebhookSecurityStateForTest, -} from "../monitor.js"; -import { setBlueBubblesRuntime } from "../runtime.js"; - -type BlueBubblesHistoryFetchResult = { - entries: HistoryEntry[]; - resolved: boolean; -}; - -export type DispatchReplyParams = Parameters< - PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"] ->[0]; - -export const EMPTY_DISPATCH_RESULT = { - queuedFinal: false, - counts: { tool: 0, block: 0, final: 0 }, -} as const; - -type BlueBubblesMonitorTestRuntimeMocks = { - enqueueSystemEvent: PluginRuntime["system"]["enqueueSystemEvent"]; - chunkMarkdownText: PluginRuntime["channel"]["text"]["chunkMarkdownText"]; - chunkByNewline: PluginRuntime["channel"]["text"]["chunkByNewline"]; - chunkMarkdownTextWithMode: PluginRuntime["channel"]["text"]["chunkMarkdownTextWithMode"]; - chunkTextWithMode: PluginRuntime["channel"]["text"]["chunkTextWithMode"]; - resolveChunkMode: PluginRuntime["channel"]["text"]["resolveChunkMode"]; - hasControlCommand: PluginRuntime["channel"]["text"]["hasControlCommand"]; - dispatchReplyWithBufferedBlockDispatcher: PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"]; - formatAgentEnvelope: PluginRuntime["channel"]["reply"]["formatAgentEnvelope"]; - formatInboundEnvelope: PluginRuntime["channel"]["reply"]["formatInboundEnvelope"]; - resolveEnvelopeFormatOptions: PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"]; - resolveAgentRoute: PluginRuntime["channel"]["routing"]["resolveAgentRoute"]; - buildPairingReply: PluginRuntime["channel"]["pairing"]["buildPairingReply"]; - readAllowFromStore: PluginRuntime["channel"]["pairing"]["readAllowFromStore"]; - upsertPairingRequest: PluginRuntime["channel"]["pairing"]["upsertPairingRequest"]; - saveMediaBuffer: PluginRuntime["channel"]["media"]["saveMediaBuffer"]; - resolveStorePath: PluginRuntime["channel"]["session"]["resolveStorePath"]; - readSessionUpdatedAt: PluginRuntime["channel"]["session"]["readSessionUpdatedAt"]; - buildMentionRegexes: PluginRuntime["channel"]["mentions"]["buildMentionRegexes"]; - matchesMentionPatterns: PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"]; - matchesMentionWithExplicit: PluginRuntime["channel"]["mentions"]["matchesMentionWithExplicit"]; - resolveGroupPolicy: PluginRuntime["channel"]["groups"]["resolveGroupPolicy"]; - resolveRequireMention: PluginRuntime["channel"]["groups"]["resolveRequireMention"]; - resolveCommandAuthorizedFromAuthorizers: PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"]; -}; - -export function createBlueBubblesMonitorTestRuntime( - mocks: BlueBubblesMonitorTestRuntimeMocks, -): PluginRuntime { - // Keep this helper small and explicit: BlueBubbles tests should only pay for the - // runtime slices monitor coverage actually consumes, while still tracking contract drift. - return createPluginRuntimeMock({ - system: { - enqueueSystemEvent: mocks.enqueueSystemEvent, - }, - channel: { - text: { - chunkMarkdownText: mocks.chunkMarkdownText, - chunkByNewline: mocks.chunkByNewline, - chunkMarkdownTextWithMode: mocks.chunkMarkdownTextWithMode, - chunkTextWithMode: mocks.chunkTextWithMode, - resolveChunkMode: mocks.resolveChunkMode, - hasControlCommand: mocks.hasControlCommand, - }, - reply: { - dispatchReplyWithBufferedBlockDispatcher: mocks.dispatchReplyWithBufferedBlockDispatcher, - formatAgentEnvelope: mocks.formatAgentEnvelope, - formatInboundEnvelope: mocks.formatInboundEnvelope, - resolveEnvelopeFormatOptions: mocks.resolveEnvelopeFormatOptions, - }, - routing: { - resolveAgentRoute: mocks.resolveAgentRoute, - }, - pairing: { - buildPairingReply: mocks.buildPairingReply, - readAllowFromStore: mocks.readAllowFromStore, - upsertPairingRequest: mocks.upsertPairingRequest, - }, - media: { - saveMediaBuffer: mocks.saveMediaBuffer, - }, - session: { - resolveStorePath: mocks.resolveStorePath, - readSessionUpdatedAt: mocks.readSessionUpdatedAt, - }, - mentions: { - buildMentionRegexes: mocks.buildMentionRegexes, - matchesMentionPatterns: mocks.matchesMentionPatterns, - matchesMentionWithExplicit: mocks.matchesMentionWithExplicit, - }, - groups: { - resolveGroupPolicy: mocks.resolveGroupPolicy, - resolveRequireMention: mocks.resolveRequireMention, - }, - commands: { - resolveCommandAuthorizedFromAuthorizers: mocks.resolveCommandAuthorizedFromAuthorizers, - }, - }, - }); -} - -export function resetBlueBubblesMonitorTestState(params: { - createRuntime: () => PluginRuntime; - fetchHistoryMock: { mockResolvedValue: (value: BlueBubblesHistoryFetchResult) => unknown }; - readAllowFromStoreMock: { mockResolvedValue: (value: string[]) => unknown }; - upsertPairingRequestMock: { - mockResolvedValue: (value: { code: string; created: boolean }) => unknown; - }; - resolveRequireMentionMock: { mockReturnValue: (value: boolean) => unknown }; - hasControlCommandMock: { mockReturnValue: (value: boolean) => unknown }; - resolveCommandAuthorizedFromAuthorizersMock: { mockReturnValue: (value: boolean) => unknown }; - buildMentionRegexesMock: { mockReturnValue: (value: RegExp[]) => unknown }; - extraReset?: () => void; -}) { - vi.clearAllMocks(); - _resetBlueBubblesShortIdState(); - _resetBlueBubblesInboundDedupForTest(); - clearBlueBubblesWebhookSecurityStateForTest(); - params.extraReset?.(); - params.fetchHistoryMock.mockResolvedValue({ entries: [], resolved: true }); - params.readAllowFromStoreMock.mockResolvedValue([]); - params.upsertPairingRequestMock.mockResolvedValue({ code: "TESTCODE", created: true }); - params.resolveRequireMentionMock.mockReturnValue(false); - params.hasControlCommandMock.mockReturnValue(false); - params.resolveCommandAuthorizedFromAuthorizersMock.mockReturnValue(false); - params.buildMentionRegexesMock.mockReturnValue([/\bbert\b/i]); - setBlueBubblesRuntime(params.createRuntime()); -} diff --git a/extensions/bluebubbles/src/types.ts b/extensions/bluebubbles/src/types.ts deleted file mode 100644 index bd2230de9d63..000000000000 --- a/extensions/bluebubbles/src/types.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { fetchWithRuntimeDispatcherOrMockedGlobal } from "openclaw/plugin-sdk/runtime-fetch"; -import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/setup"; -import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; - -type BlueBubblesGroupConfig = { - /** If true, only respond in this group when mentioned. */ - requireMention?: boolean; - /** Optional tool policy overrides for this group. */ - tools?: { allow?: string[]; deny?: string[] }; - /** - * Free-form directive appended to the system prompt on every turn that - * handles a message in this group. - */ - systemPrompt?: string; -}; - -type BlueBubblesActionConfig = { - reactions?: boolean; - edit?: boolean; - unsend?: boolean; - reply?: boolean; - sendWithEffect?: boolean; - renameGroup?: boolean; - setGroupIcon?: boolean; - addParticipant?: boolean; - removeParticipant?: boolean; - leaveGroup?: boolean; - sendAttachment?: boolean; -}; - -type BlueBubblesNetworkConfig = { - /** Dangerous opt-in for same-host or trusted private/internal BlueBubbles deployments. */ - dangerouslyAllowPrivateNetwork?: boolean; -}; - -export type BlueBubblesAccountConfig = { - /** Optional display name for this account (used in CLI/UI lists). */ - name?: string; - /** Optional provider capability tags used for agent/runtime guidance. */ - capabilities?: string[]; - /** Allow channel-initiated config writes (default: true). */ - configWrites?: boolean; - /** If false, do not start this BlueBubbles account. Default: true. */ - enabled?: boolean; - /** Base URL for the BlueBubbles API. */ - serverUrl?: string; - /** Password for BlueBubbles API authentication. */ - password?: string; - /** Webhook path for the gateway HTTP server. */ - webhookPath?: string; - /** Direct message access policy (default: pairing). */ - dmPolicy?: DmPolicy; - allowFrom?: Array; - /** Optional allowlist for group senders. */ - groupAllowFrom?: Array; - /** Group message handling policy. */ - groupPolicy?: GroupPolicy; - /** Enrich unnamed group participants with local macOS Contacts names after gating. Default: true. */ - enrichGroupParticipantsFromContacts?: boolean; - /** Max group messages to keep as history context (0 disables). */ - historyLimit?: number; - /** Max DM turns to keep as history context. */ - dmHistoryLimit?: number; - /** Per-DM config overrides keyed by user ID. */ - dms?: Record; - /** Outbound text chunk size (chars). Default: 4000. */ - textChunkLimit?: number; - /** - * Per-request timeout (ms) for outbound text sends via - * `/api/v1/message/text` and the `createNewChatWithMessage` send path. - * Probes, chat lookups, catchup, and history keep the shorter default. - * Raise this on macOS 26 setups where Private API iMessage sends can stall - * for 60+s. Default: 30000. - * - * Reaction and edit paths (`sendBlueBubblesReaction`, - * `editBlueBubblesMessage`, `unsendBlueBubblesMessage`) still honor the - * shorter client default unless the caller passes `opts.timeoutMs` — covering - * those uniformly from config is tracked as a follow-up. (#67486) - */ - sendTimeoutMs?: number; - /** Chunking mode: "newline" (default) splits on every newline; "length" splits by size. */ - chunkMode?: "length" | "newline"; - blockStreaming?: boolean; - /** Merge streamed block replies before sending. */ - blockStreamingCoalesce?: Record; - /** - * When an inbound reply lands without `replyToBody`/`replyToSender` and the - * in-memory reply cache misses (e.g., multi-instance deployments sharing - * one BlueBubbles account, after process restarts, or after long-lived - * cache eviction), fetch the original message from the BlueBubbles HTTP API - * as a best-effort fallback. Default: false. - */ - replyContextApiFallback?: boolean; - /** Max outbound media size in MB. */ - mediaMaxMb?: number; - /** - * Explicit allowlist of local directory roots permitted for outbound media paths. - * Local paths are rejected unless they resolve under one of these roots. - */ - mediaLocalRoots?: string[]; - /** Send read receipts for incoming messages (default: true). */ - sendReadReceipts?: boolean; - /** Network policy overrides for same-host or trusted private/internal BlueBubbles deployments. */ - network?: BlueBubblesNetworkConfig; - /** Per-group configuration keyed by chat GUID or identifier. */ - groups?: Record; - /** Per-action tool gating (default: true for all). */ - actions?: BlueBubblesActionConfig; - /** Channel health monitor overrides for this channel/account. */ - healthMonitor?: { - enabled?: boolean; - }; - /** - * When true, consecutive DM messages (`isGroup === false`) from the same - * sender within the inbound debounce window coalesce into a single agent - * turn. Keys by `chat:sender` instead of the per-message `messageId` so - * "command + payload as two sends" (e.g. a `dump` command followed by a - * pasted URL that iMessage renders as its own URL balloon) reaches the - * agent together. Does not apply to group chats or to BlueBubbles - * text+balloon follow-ups, which still coalesce via - * `associatedMessageGuid`. Default: false. - */ - coalesceSameSenderDms?: boolean; -}; - -export type BlueBubblesSendTarget = - | { kind: "chat_id"; chatId: number } - | { kind: "chat_guid"; chatGuid: string } - | { kind: "chat_identifier"; chatIdentifier: string } - | { kind: "handle"; address: string; service?: "imessage" | "sms" | "auto" }; - -export type BlueBubblesAttachment = { - guid?: string; - uti?: string; - mimeType?: string; - transferName?: string; - totalBytes?: number; - height?: number; - width?: number; - originalROWID?: number; -}; - -const DEFAULT_TIMEOUT_MS = 10_000; - -/** - * Default timeout for outbound message sends via `/api/v1/message/text` and - * the `createNewChatWithMessage` flow. Larger than `DEFAULT_TIMEOUT_MS` because - * Private API iMessage sends on macOS 26 (Tahoe) can stall for 60+ seconds - * inside the iMessage framework. Callers can override per-call via - * `opts.timeoutMs` or per-account via `channels.bluebubbles.sendTimeoutMs`. - * (#67486) - */ -export const DEFAULT_SEND_TIMEOUT_MS = 30_000; - -export function normalizeBlueBubblesServerUrl(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed) { - throw new Error("BlueBubbles serverUrl is required"); - } - const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; - return withScheme.replace(/\/+$/, ""); -} - -// Overridable guard for testing; production code uses fetchWithSsrFGuard. -let _fetchGuard = fetchWithSsrFGuard; - -/** @internal Replace the SSRF fetch guard in tests. */ -export function _setFetchGuardForTesting( - impl: - | ((...args: Parameters) => ReturnType) - | null, -): void { - _fetchGuard = impl ?? fetchWithSsrFGuard; -} - -export async function blueBubblesFetchWithTimeout( - url: string, - init: RequestInit, - timeoutMs = DEFAULT_TIMEOUT_MS, - ssrfPolicy?: SsrFPolicy, -): Promise { - if (ssrfPolicy !== undefined) { - // Use SSRF-guarded fetch; buffer the body so the dispatcher can be released - // before the caller reads the response (API responses are small JSON payloads). - const { response, release } = await _fetchGuard({ - url, - init, - timeoutMs, - policy: ssrfPolicy, - auditContext: "bluebubbles-api", - }); - // Null-body status codes per Fetch spec — Response constructor rejects a body for these. - const isNullBody = - response.status === 101 || - response.status === 204 || - response.status === 205 || - response.status === 304; - try { - const bodyBytes = isNullBody ? null : await response.arrayBuffer(); - return new Response(bodyBytes, { status: response.status, headers: response.headers }); - } finally { - await release(); - } - } - // Strip `dispatcher` from init — the SSRF guard may have attached a bundled-undici - // dispatcher that is incompatible with Node 22+'s built-in undici backing globalThis.fetch(). - // Passing it through causes a silent TypeError (invalid onRequestStart method). - // The SSRF validation already completed upstream in fetchWithSsrFGuard before calling - // this function as fetchImpl, so stripping the dispatcher does not weaken security. (#64105) - const { dispatcher: _dispatcher, ...safeInit } = (init ?? {}) as RequestInit & { - dispatcher?: unknown; - }; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - return await fetchWithRuntimeDispatcherOrMockedGlobal(url, { - ...safeInit, - signal: controller.signal, - }); - } finally { - clearTimeout(timer); - } -} diff --git a/extensions/bluebubbles/src/webhook-ingress.ts b/extensions/bluebubbles/src/webhook-ingress.ts deleted file mode 100644 index 27a3d6546bb4..000000000000 --- a/extensions/bluebubbles/src/webhook-ingress.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { - WEBHOOK_RATE_LIMIT_DEFAULTS, - createFixedWindowRateLimiter, - createWebhookInFlightLimiter, - registerWebhookTargetWithPluginRoute, - readWebhookBodyOrReject, - resolveRequestClientIp, - resolveWebhookTargetWithAuthOrRejectSync, - withResolvedWebhookRequestPipeline, -} from "openclaw/plugin-sdk/webhook-ingress"; diff --git a/extensions/bluebubbles/src/webhook-shared.ts b/extensions/bluebubbles/src/webhook-shared.ts deleted file mode 100644 index 8bc4f1d07584..000000000000 --- a/extensions/bluebubbles/src/webhook-shared.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; -import { normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-path"; -import type { BlueBubblesAccountConfig } from "./types.js"; - -export { normalizeWebhookPath }; - -export const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook"; - -export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string { - const raw = normalizeOptionalString(config?.webhookPath); - if (raw) { - return normalizeWebhookPath(raw); - } - return DEFAULT_WEBHOOK_PATH; -} diff --git a/extensions/bluebubbles/tsconfig.json b/extensions/bluebubbles/tsconfig.json deleted file mode 100644 index b8a85a99ac3d..000000000000 --- a/extensions/bluebubbles/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../tsconfig.package-boundary.base.json", - "compilerOptions": { - "rootDir": "." - }, - "include": ["./*.ts", "./src/**/*.ts"], - "exclude": [ - "./**/*.test.ts", - "./dist/**", - "./node_modules/**", - "./src/test-support/**", - "./src/**/*test-helpers.ts", - "./src/**/*test-harness.ts", - "./src/**/*test-support.ts" - ] -} diff --git a/skills/bluebubbles/SKILL.md b/skills/bluebubbles/SKILL.md deleted file mode 100644 index 1bb9a90e5e43..000000000000 --- a/skills/bluebubbles/SKILL.md +++ /dev/null @@ -1,131 +0,0 @@ ---- -name: bluebubbles -description: Send and manage iMessages via BlueBubbles, including attachments, tapbacks, edits, replies, and groups. -metadata: { "openclaw": { "emoji": "🫧", "requires": { "config": ["channels.bluebubbles"] } } } ---- - -# BlueBubbles Actions - -## Overview - -BlueBubbles is OpenClaw's legacy iMessage bridge. Use the `message` tool with `channel: "bluebubbles"` for existing BlueBubbles-backed conversations that need attachments, tapbacks, edit/unsend, replies, or group management. - -## Inputs to collect - -- `target` (prefer `chat_guid:...`; also `+15551234567` in E.164 or `user@example.com`) -- `message` text for send/edit/reply -- `messageId` for react/edit/unsend/reply -- Attachment `path` for local files, or `buffer` + `filename` for base64 - -If the user is vague ("text my mom"), ask for the recipient handle or chat guid and the exact message content. - -## Actions - -### Send a message - -```json -{ - "action": "send", - "channel": "bluebubbles", - "target": "+15551234567", - "message": "hello from OpenClaw" -} -``` - -### React (tapback) - -```json -{ - "action": "react", - "channel": "bluebubbles", - "target": "+15551234567", - "messageId": "", - "emoji": "❤️" -} -``` - -### Remove a reaction - -```json -{ - "action": "react", - "channel": "bluebubbles", - "target": "+15551234567", - "messageId": "", - "emoji": "❤️", - "remove": true -} -``` - -### Edit a previously sent message - -```json -{ - "action": "edit", - "channel": "bluebubbles", - "target": "+15551234567", - "messageId": "", - "message": "updated text" -} -``` - -### Unsend a message - -```json -{ - "action": "unsend", - "channel": "bluebubbles", - "target": "+15551234567", - "messageId": "" -} -``` - -### Reply to a specific message - -```json -{ - "action": "reply", - "channel": "bluebubbles", - "target": "+15551234567", - "replyTo": "", - "message": "replying to that" -} -``` - -### Send an attachment - -```json -{ - "action": "sendAttachment", - "channel": "bluebubbles", - "target": "+15551234567", - "path": "/tmp/photo.jpg", - "caption": "here you go" -} -``` - -### Send with an iMessage effect - -```json -{ - "action": "sendWithEffect", - "channel": "bluebubbles", - "target": "+15551234567", - "message": "big news", - "effect": "balloons" -} -``` - -## Notes - -- Requires gateway config `channels.bluebubbles` (serverUrl/password/webhookPath). -- Prefer `chat_guid` targets when you have them (especially for group chats). -- BlueBubbles supports rich actions, but some are macOS-version dependent (for example, edit may be broken on macOS 26 Tahoe). -- The gateway may expose both short and full message ids; full ids are more durable across restarts. -- Developer reference for the underlying plugin lives in the BlueBubbles plugin package README. - -## Ideas to try - -- React with a tapback to acknowledge a request. -- Reply in-thread when a user references a specific message. -- Send a file attachment with a short caption. diff --git a/src/channels/plugins/bluebubbles-actions.ts b/src/channels/plugins/bluebubbles-actions.ts deleted file mode 100644 index 6b47be318c22..000000000000 --- a/src/channels/plugins/bluebubbles-actions.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ChannelMessageActionName } from "./types.public.js"; - -export type BlueBubblesActionSpec = { - gate: string; - groupOnly?: boolean; - unsupportedOnMacOS26?: boolean; -}; - -export const BLUEBUBBLES_ACTIONS = { - react: { gate: "reactions" }, - edit: { gate: "edit", unsupportedOnMacOS26: true }, - unsend: { gate: "unsend" }, - reply: { gate: "reply" }, - sendWithEffect: { gate: "sendWithEffect" }, - renameGroup: { gate: "renameGroup", groupOnly: true }, - setGroupIcon: { gate: "setGroupIcon", groupOnly: true }, - addParticipant: { gate: "addParticipant", groupOnly: true }, - removeParticipant: { gate: "removeParticipant", groupOnly: true }, - leaveGroup: { gate: "leaveGroup", groupOnly: true }, - sendAttachment: { gate: "sendAttachment" }, -} as const satisfies Partial>; - -export const BLUEBUBBLES_ACTION_NAMES = Object.keys( - BLUEBUBBLES_ACTIONS, -) as (keyof typeof BLUEBUBBLES_ACTIONS)[]; diff --git a/src/plugin-sdk/bluebubbles-policy.ts b/src/plugin-sdk/bluebubbles-policy.ts deleted file mode 100644 index ae9dc7976ea8..000000000000 --- a/src/plugin-sdk/bluebubbles-policy.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Manual facade. Keep loader boundary explicit. -import type { OpenClawConfig } from "../config/types.js"; -import type { GroupToolPolicyConfig } from "../config/types.tools.js"; -import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-loader.js"; - -type BlueBubblesGroupContext = { - cfg: OpenClawConfig; - accountId?: string | null; - groupId?: string | null; - senderId?: string | null; - senderName?: string | null; - senderUsername?: string | null; - senderE164?: string | null; -}; - -type FacadeModule = { - isAllowedBlueBubblesSender: (params: { - allowFrom: Array; - sender: string; - chatId?: number | null; - chatGuid?: string | null; - chatIdentifier?: string | null; - }) => boolean; - resolveBlueBubblesGroupRequireMention: (params: BlueBubblesGroupContext) => boolean; - resolveBlueBubblesGroupToolPolicy: ( - params: BlueBubblesGroupContext, - ) => GroupToolPolicyConfig | undefined; -}; - -function loadFacadeModule(): FacadeModule { - return loadBundledPluginPublicSurfaceModuleSync({ - dirName: "bluebubbles", - artifactBasename: "api.js", - }); -} -export const isAllowedBlueBubblesSender: FacadeModule["isAllowedBlueBubblesSender"] = ((...args) => - loadFacadeModule()["isAllowedBlueBubblesSender"]( - ...args, - )) as FacadeModule["isAllowedBlueBubblesSender"]; -export const resolveBlueBubblesGroupRequireMention: FacadeModule["resolveBlueBubblesGroupRequireMention"] = - ((...args) => - loadFacadeModule()["resolveBlueBubblesGroupRequireMention"]( - ...args, - )) as FacadeModule["resolveBlueBubblesGroupRequireMention"]; -export const resolveBlueBubblesGroupToolPolicy: FacadeModule["resolveBlueBubblesGroupToolPolicy"] = - ((...args) => - loadFacadeModule()["resolveBlueBubblesGroupToolPolicy"]( - ...args, - )) as FacadeModule["resolveBlueBubblesGroupToolPolicy"]; diff --git a/src/plugin-sdk/bluebubbles.test.ts b/src/plugin-sdk/bluebubbles.test.ts deleted file mode 100644 index dbb0b595f26d..000000000000 --- a/src/plugin-sdk/bluebubbles.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const loadBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn()); - -vi.mock("./facade-loader.js", () => ({ - loadBundledPluginPublicSurfaceModuleSync, -})); - -describe("plugin-sdk bluebubbles facade", () => { - beforeEach(() => { - vi.resetModules(); - loadBundledPluginPublicSurfaceModuleSync.mockReset(); - }); - - it("delegates conversation matching helpers to the plugin public facade", async () => { - const normalized = { conversationId: "+15551234567" }; - const match = { conversationId: "+15551234567", matchPriority: 2 }; - const normalizeBlueBubblesAcpConversationId = vi.fn().mockReturnValue(normalized); - const matchBlueBubblesAcpConversation = vi.fn().mockReturnValue(match); - const resolveBlueBubblesConversationIdFromTarget = vi.fn().mockReturnValue("+15551234567"); - loadBundledPluginPublicSurfaceModuleSync.mockReturnValue({ - normalizeBlueBubblesAcpConversationId, - matchBlueBubblesAcpConversation, - resolveBlueBubblesConversationIdFromTarget, - }); - - const bluebubbles = await import("./bluebubbles.js"); - - expect(bluebubbles.normalizeBlueBubblesAcpConversationId("sms:+15551234567")).toBe(normalized); - expect( - bluebubbles.matchBlueBubblesAcpConversation({ - bindingConversationId: "+15551234567", - conversationId: "sms:+15551234567", - }), - ).toBe(match); - expect(bluebubbles.resolveBlueBubblesConversationIdFromTarget("sms:+15551234567")).toBe( - "+15551234567", - ); - expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({ - dirName: "bluebubbles", - artifactBasename: "api.js", - }); - expect(normalizeBlueBubblesAcpConversationId).toHaveBeenCalledWith("sms:+15551234567"); - expect(matchBlueBubblesAcpConversation).toHaveBeenCalledWith({ - bindingConversationId: "+15551234567", - conversationId: "sms:+15551234567", - }); - expect(resolveBlueBubblesConversationIdFromTarget).toHaveBeenCalledWith("sms:+15551234567"); - }); -}); diff --git a/src/plugin-sdk/bluebubbles.ts b/src/plugin-sdk/bluebubbles.ts deleted file mode 100644 index 22b02f475456..000000000000 --- a/src/plugin-sdk/bluebubbles.ts +++ /dev/null @@ -1,159 +0,0 @@ -import type { ChannelStatusIssue } from "../channels/plugins/types.public.js"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-loader.js"; - -// Narrow plugin-sdk surface for the bundled BlueBubbles plugin. -// Keep this list additive and scoped to the conversation-binding seam only. - -export type BlueBubblesConversationBindingManager = { - stop: () => void; -}; - -type BlueBubblesFacadeModule = { - createBlueBubblesConversationBindingManager: (params: { - accountId?: string; - cfg: OpenClawConfig; - }) => BlueBubblesConversationBindingManager; - normalizeBlueBubblesAcpConversationId: ( - conversationId: string, - ) => { conversationId: string } | null; - matchBlueBubblesAcpConversation: (params: { - bindingConversationId: string; - conversationId: string; - }) => { conversationId: string; matchPriority: number } | null; - resolveBlueBubblesConversationIdFromTarget: (target: string) => string | undefined; - collectBlueBubblesStatusIssues: (accounts: unknown[]) => ChannelStatusIssue[]; -}; - -function loadBlueBubblesFacadeModule(): BlueBubblesFacadeModule { - return loadBundledPluginPublicSurfaceModuleSync({ - dirName: "bluebubbles", - artifactBasename: "api.js", - }); -} - -export function createBlueBubblesConversationBindingManager(params: { - accountId?: string; - cfg: OpenClawConfig; -}): BlueBubblesConversationBindingManager { - return loadBlueBubblesFacadeModule().createBlueBubblesConversationBindingManager(params); -} - -export function normalizeBlueBubblesAcpConversationId( - conversationId: string, -): { conversationId: string } | null { - return loadBlueBubblesFacadeModule().normalizeBlueBubblesAcpConversationId(conversationId); -} - -export function matchBlueBubblesAcpConversation(params: { - bindingConversationId: string; - conversationId: string; -}): { conversationId: string; matchPriority: number } | null { - return loadBlueBubblesFacadeModule().matchBlueBubblesAcpConversation(params); -} - -export function resolveBlueBubblesConversationIdFromTarget(target: string): string | undefined { - return loadBlueBubblesFacadeModule().resolveBlueBubblesConversationIdFromTarget(target); -} - -export function collectBlueBubblesStatusIssues(accounts: unknown[]): ChannelStatusIssue[] { - return loadBlueBubblesFacadeModule().collectBlueBubblesStatusIssues(accounts); -} - -export { resolveAckReaction } from "../agents/identity.js"; -export { - createActionGate, - jsonResult, - readNumberParam, - readReactionParams, - readStringParam, -} from "../agents/tools/common.js"; -export type { HistoryEntry } from "../auto-reply/reply/history.js"; -export { - evictOldHistoryKeys, - recordPendingHistoryEntryIfEnabled, -} from "../auto-reply/reply/history.js"; -export { resolveControlCommandGate } from "../channels/command-gating.js"; -export { logAckFailure, logInboundDrop, logTypingFailure } from "../channels/logging.js"; -export { - BLUEBUBBLES_ACTION_NAMES, - BLUEBUBBLES_ACTIONS, -} from "../channels/plugins/bluebubbles-actions.js"; -export { - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, -} from "../channels/plugins/config-helpers.js"; -export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export { - resolveBlueBubblesGroupRequireMention, - resolveBlueBubblesGroupToolPolicy, -} from "./bluebubbles-policy.js"; -export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; -export { - addWildcardAllowFrom, - mergeAllowFromEntries, - setTopLevelChannelDmPolicyWithAllowFrom, -} from "../channels/plugins/setup-wizard-helpers.js"; -export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; -export { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, - patchScopedAccountConfig, -} from "../channels/plugins/setup-helpers.js"; -export { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; -export type { - BaseProbeResult, - ChannelAccountSnapshot, - ChannelMessageActionAdapter, - ChannelMessageActionName, -} from "../channels/plugins/types.public.js"; -export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; -export { createChannelReplyPipeline } from "./channel-reply-core.js"; -export type { OpenClawConfig } from "../config/config.js"; -export type { DmPolicy, GroupPolicy } from "../config/types.js"; -export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; -export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; -export { - parseChatAllowTargetPrefixes, - parseChatTargetPrefixesOrThrow, - resolveServicePrefixedAllowTarget, - resolveServicePrefixedTarget, - type ParsedChatTarget, -} from "./channel-targets.js"; -export { stripMarkdown } from "./text-runtime.js"; -export { parseFiniteNumber } from "../infra/parse-finite-number.js"; -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; -export { - DM_GROUP_ACCESS_REASON, - readStoreAllowFromForDmPolicy, - resolveDmGroupAccessWithLists, -} from "../security/dm-policy-shared.js"; -export { formatDocsLink } from "../terminal/links.js"; -export type { WizardPrompter } from "../wizard/prompts.js"; -export { isAllowedParsedChatSender } from "./allow-from.js"; -export { readBooleanParam } from "./boolean-param.js"; -export { mapAllowFromEntries } from "./channel-config-helpers.js"; -export { createChannelPairingController } from "./channel-pairing.js"; -export { resolveRequestUrl } from "./request-url.js"; -export { - buildComputedAccountStatusSnapshot, - buildProbeChannelStatusSummary, -} from "./status-helpers.js"; -export { isAllowedBlueBubblesSender } from "./bluebubbles-policy.js"; -export { extractToolSend } from "./tool-send.js"; -export { - WEBHOOK_RATE_LIMIT_DEFAULTS, - createFixedWindowRateLimiter, - createWebhookInFlightLimiter, - normalizeWebhookPath, - readWebhookBodyOrReject, - registerWebhookTargetWithPluginRoute, - resolveRequestClientIp, - resolveWebhookTargets, - resolveWebhookTargetWithAuthOrRejectSync, - withResolvedWebhookRequestPipeline, -} from "./webhook-ingress.js"; diff --git a/test/vitest/vitest.extension-bluebubbles-paths.mjs b/test/vitest/vitest.extension-bluebubbles-paths.mjs deleted file mode 100644 index e119979fbe88..000000000000 --- a/test/vitest/vitest.extension-bluebubbles-paths.mjs +++ /dev/null @@ -1,5 +0,0 @@ -export const blueBubblesExtensionTestRoots = ["extensions/bluebubbles"]; - -export function isBlueBubblesExtensionRoot(root) { - return blueBubblesExtensionTestRoots.includes(root); -} diff --git a/test/vitest/vitest.extension-bluebubbles.config.ts b/test/vitest/vitest.extension-bluebubbles.config.ts deleted file mode 100644 index f9931ee732f1..000000000000 --- a/test/vitest/vitest.extension-bluebubbles.config.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { blueBubblesExtensionTestRoots } from "./vitest.extension-bluebubbles-paths.mjs"; -import { loadPatternListFromEnv } from "./vitest.pattern-file.ts"; -import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; - -export function loadIncludePatternsFromEnv( - env: Record = process.env, -): string[] | null { - return loadPatternListFromEnv("OPENCLAW_VITEST_INCLUDE_FILE", env); -} - -export function createExtensionBlueBubblesVitestConfig( - env: Record = process.env, -) { - return createScopedVitestConfig( - loadIncludePatternsFromEnv(env) ?? - blueBubblesExtensionTestRoots.map((root) => `${root}/**/*.test.ts`), - { - dir: "extensions", - env, - name: "extension-bluebubbles", - passWithNoTests: true, - setupFiles: ["test/setup.extensions.ts"], - }, - ); -} - -export default createExtensionBlueBubblesVitestConfig();