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