diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ee31fe23..c00f18f94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.clawd.bot - Plugins: add exclusive plugin slots with a dedicated memory slot selector. - Memory: ship core memory tools + CLI as the bundled `memory-core` plugin. - Docs: document plugin slots and memory plugin behavior. +- Plugins: add the bundled BlueBubbles channel plugin (disabled by default). - Plugins: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader. ## 2026.1.17-5 diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md new file mode 100644 index 000000000..a2b96dbe3 --- /dev/null +++ b/docs/channels/bluebubbles.md @@ -0,0 +1,64 @@ +--- +summary: "iMessage via BlueBubbles macOS server (REST send/receive, typing, reactions, pairing)." +read_when: + - Setting up BlueBubbles channel + - Troubleshooting webhook pairing +--- +# BlueBubbles (macOS REST) + +Status: bundled plugin (disabled by default) that talks to the BlueBubbles macOS server over HTTP. + +## Overview +- Runs on macOS via the BlueBubbles helper app (`https://bluebubbles.app`). +- Clawdbot 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). +- Pairing/allowlist works the same way as other channels (`/start/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. + +## Quick start +1. Install the BlueBubbles server on your Mac (follows the app store instructions at `https://bluebubbles.app/install`). +2. In the BlueBubbles config, enable the web API and set a password for `guid`/`password`. +3. Configure Clawdbot: + ```json5 + { + channels: { + bluebubbles: { + enabled: true, + serverUrl: "http://bluebubbles-host:1234", + password: "example-password", + webhookPath: "/bluebubbles-webhook", + actions: { reactions: true } + } + } + } + ``` +4. Point BlueBubbles webhooks to your gateway (example: `http://your-gateway-host/bluebubbles-webhook?password=`). +5. Start the gateway; it will register the webhook handler and start pairing. + +## Configuration notes +- `channels.bluebubbles.serverUrl`: base URL of the BlueBubbles REST API. +- `channels.bluebubbles.password`: password that BlueBubbles expects on every request (`?password=...` or header). +- `channels.bluebubbles.webhookPath`: HTTP path the gateway exposes for BlueBubbles webhooks. +- `channels.bluebubbles.dmPolicy` / `groupPolicy` + `allowFrom`/`groupAllowFrom` behave like other channels; pairing/allowlist info is stored in `/pairing`. +- `channels.bluebubbles.actions.reactions` toggles whether the gateway enqueues system events for reactions/tapbacks. +- `channels.bluebubbles.textChunkLimit` overrides the default 4k limit. +- `channels.bluebubbles.mediaMaxMb` controls the max size of inbound attachments saved for analysis (default 8MB). + +## How it works +- Outbound replies: `sendMessageBlueBubbles` resolves a chat GUID via `/api/v1/chat/query` and posts to `/api/v1/message/text`. Typing (`/api/v1/chat//typing`) and read receipts (`/api/v1/chat//read`) are sent before/after responses. +- Webhooks: BlueBubbles POSTs JSON payloads with `type` and `data`. The plugin ignores non-message events (typing indicator, read status) and extracts `chatGuid` from `data.chats[0].guid`. +- Reactions/tapbacks generate `BlueBubbles reaction added/removed` system events so agents can mention them. Agents can also trigger tapbacks via the `react` action with `messageId`, `emoji`, and a `to`/`chatGuid`. +- Attachments are downloaded via the REST API and stored in the inbound media cache; text-less messages are converted into `` placeholders so the agent knows something was sent. + +## Security +- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted. +- Keep the API password and webhook endpoint secret (treat them like credentials). +- Enable HTTPS + firewall rules on the BlueBubbles server if exposing it outside your LAN. + +## Troubleshooting +- If Voice/typing events stop working, check the BlueBubbles webhook logs and verify the gateway path matches `channels.bluebubbles.webhookPath`. +- Pairing codes expire after one hour; use `clawdbot pairing list bluebubbles` and `clawdbot pairing approve bluebubbles `. +- Reactions require the BlueBubbles private API (`POST /api/v1/message/react`); ensure the server version exposes it. + +For general channel workflow reference, see [/channels/index] and the [[plugins|/plugin]] guide. diff --git a/docs/channels/index.md b/docs/channels/index.md index 3021d5237..d294cb03c 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -17,6 +17,7 @@ Text is supported everywhere; media and reactions vary by channel. - [Slack](/channels/slack) — Bolt SDK; workspace apps. - [Signal](/channels/signal) — signal-cli; privacy-focused. - [iMessage](/channels/imessage) — macOS only; native integration. +- [BlueBubbles](/channels/bluebubbles) — iMessage via BlueBubbles macOS server (bundled plugin, disabled by default). - [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately). - [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately). - [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately). diff --git a/extensions/bluebubbles/index.ts b/extensions/bluebubbles/index.ts new file mode 100644 index 000000000..79b3cad7a --- /dev/null +++ b/extensions/bluebubbles/index.ts @@ -0,0 +1,18 @@ +import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; + +import { bluebubblesPlugin } from "./src/channel.js"; +import { handleBlueBubblesWebhookRequest } from "./src/monitor.js"; +import { setBlueBubblesRuntime } from "./src/runtime.js"; + +const plugin = { + id: "bluebubbles", + name: "BlueBubbles", + description: "BlueBubbles channel plugin (macOS app)", + register(api: ClawdbotPluginApi) { + setBlueBubblesRuntime(api.runtime); + api.registerChannel({ plugin: bluebubblesPlugin }); + api.registerHttpHandler(handleBlueBubblesWebhookRequest); + }, +}; + +export default plugin; diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json new file mode 100644 index 000000000..db43a0023 --- /dev/null +++ b/extensions/bluebubbles/package.json @@ -0,0 +1,9 @@ +{ + "name": "@clawdbot/bluebubbles", + "version": "2026.1.15", + "type": "module", + "description": "Clawdbot BlueBubbles channel plugin", + "clawdbot": { + "extensions": ["./index.ts"] + } +} diff --git a/extensions/bluebubbles/src/accounts.ts b/extensions/bluebubbles/src/accounts.ts new file mode 100644 index 000000000..5a4fee8ba --- /dev/null +++ b/extensions/bluebubbles/src/accounts.ts @@ -0,0 +1,79 @@ +import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk"; +import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js"; + +export type ResolvedBlueBubblesAccount = { + accountId: string; + enabled: boolean; + name?: string; + config: BlueBubblesAccountConfig; + configured: boolean; + baseUrl?: string; +}; + +function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] { + const accounts = cfg.channels?.bluebubbles?.accounts; + if (!accounts || typeof accounts !== "object") return []; + return Object.keys(accounts).filter(Boolean); +} + +export function listBlueBubblesAccountIds(cfg: ClawdbotConfig): string[] { + const ids = listConfiguredAccountIds(cfg); + if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; + return ids.sort((a, b) => a.localeCompare(b)); +} + +export function resolveDefaultBlueBubblesAccountId(cfg: ClawdbotConfig): string { + const ids = listBlueBubblesAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +function resolveAccountConfig( + cfg: ClawdbotConfig, + accountId: string, +): BlueBubblesAccountConfig | undefined { + const accounts = cfg.channels?.bluebubbles?.accounts; + if (!accounts || typeof accounts !== "object") return undefined; + return accounts[accountId] as BlueBubblesAccountConfig | undefined; +} + +function mergeBlueBubblesAccountConfig( + cfg: ClawdbotConfig, + accountId: string, +): BlueBubblesAccountConfig { + const base = (cfg.channels?.bluebubbles ?? {}) as BlueBubblesAccountConfig & { + accounts?: unknown; + }; + const { accounts: _ignored, ...rest } = base; + const account = resolveAccountConfig(cfg, accountId) ?? {}; + return { ...rest, ...account }; +} + +export function resolveBlueBubblesAccount(params: { + cfg: ClawdbotConfig; + accountId?: string | null; +}): ResolvedBlueBubblesAccount { + const accountId = normalizeAccountId(params.accountId); + const baseEnabled = params.cfg.channels?.bluebubbles?.enabled; + const merged = mergeBlueBubblesAccountConfig(params.cfg, accountId); + const accountEnabled = merged.enabled !== false; + const serverUrl = merged.serverUrl?.trim(); + const password = merged.password?.trim(); + const configured = Boolean(serverUrl && password); + const baseUrl = serverUrl ? normalizeBlueBubblesServerUrl(serverUrl) : undefined; + return { + accountId, + enabled: baseEnabled !== false && accountEnabled, + name: merged.name?.trim() || undefined, + config: merged, + configured, + baseUrl, + }; +} + +export function listEnabledBlueBubblesAccounts(cfg: ClawdbotConfig): ResolvedBlueBubblesAccount[] { + return listBlueBubblesAccountIds(cfg) + .map((accountId) => resolveBlueBubblesAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts new file mode 100644 index 000000000..952895e7e --- /dev/null +++ b/extensions/bluebubbles/src/actions.ts @@ -0,0 +1,121 @@ +import { + createActionGate, + jsonResult, + readNumberParam, + readReactionParams, + readStringParam, + type ChannelMessageActionAdapter, + type ChannelMessageActionName, + type ChannelToolSend, + type ClawdbotConfig, +} from "clawdbot/plugin-sdk"; + +import { resolveBlueBubblesAccount } from "./accounts.js"; +import { sendBlueBubblesReaction } from "./reactions.js"; +import { resolveChatGuidForTarget } from "./send.js"; +import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js"; +import type { BlueBubblesSendTarget } from "./types.js"; + +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, + }; +} + +export const bluebubblesMessageActions: ChannelMessageActionAdapter = { + listActions: ({ cfg }) => { + const account = resolveBlueBubblesAccount({ cfg: cfg as ClawdbotConfig }); + if (!account.enabled || !account.configured) return []; + const gate = createActionGate((cfg as ClawdbotConfig).channels?.bluebubbles?.actions); + const actions = new Set(); + if (gate("reactions")) actions.add("react"); + return Array.from(actions); + }, + supportsAction: ({ action }) => action === "react", + extractToolSend: ({ args }): ChannelToolSend | null => { + const action = typeof args.action === "string" ? args.action.trim() : ""; + if (action !== "sendMessage") return null; + const to = typeof args.to === "string" ? args.to : undefined; + if (!to) return null; + const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined; + return { to, accountId }; + }, + handleAction: async ({ action, params, cfg, accountId }) => { + if (action !== "react") { + throw new Error(`Action ${action} is not supported for provider ${providerId}.`); + } + const { emoji, remove, isEmpty } = readReactionParams(params, { + removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.", + }); + if (isEmpty && !remove) { + throw new Error("Emoji is required to send a BlueBubbles reaction."); + } + const messageId = readStringParam(params, "messageId", { required: true }); + const chatGuid = readStringParam(params, "chatGuid"); + const chatIdentifier = readStringParam(params, "chatIdentifier"); + const chatId = readNumberParam(params, "chatId", { integer: true }); + const to = readStringParam(params, "to"); + const partIndex = readNumberParam(params, "partIndex", { integer: true }); + + const account = resolveBlueBubblesAccount({ + cfg: cfg as ClawdbotConfig, + accountId: accountId ?? undefined, + }); + const baseUrl = account.config.serverUrl?.trim(); + const password = account.config.password?.trim(); + + let resolvedChatGuid = chatGuid?.trim() || ""; + if (!resolvedChatGuid) { + const target = + chatIdentifier?.trim() + ? ({ kind: "chat_identifier", chatIdentifier: chatIdentifier.trim() } as BlueBubblesSendTarget) + : typeof chatId === "number" + ? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget) + : to + ? mapTarget(to) + : null; + if (!target) { + throw new Error("BlueBubbles reaction requires chatGuid, chatIdentifier, chatId, or to."); + } + if (!baseUrl || !password) { + throw new Error("BlueBubbles reaction requires serverUrl and password."); + } + resolvedChatGuid = + (await resolveChatGuidForTarget({ + baseUrl, + password, + target, + })) ?? ""; + } + if (!resolvedChatGuid) { + throw new Error("BlueBubbles reaction failed: chatGuid not found for target."); + } + + await sendBlueBubblesReaction({ + chatGuid: resolvedChatGuid, + messageGuid: messageId, + emoji, + remove: remove || undefined, + partIndex: typeof partIndex === "number" ? partIndex : undefined, + opts: { + cfg: cfg as ClawdbotConfig, + accountId: accountId ?? undefined, + }, + }); + + if (!remove) { + return jsonResult({ ok: true, added: emoji }); + } + return jsonResult({ ok: true, removed: true }); + }, +}; diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts new file mode 100644 index 000000000..fe2f904e2 --- /dev/null +++ b/extensions/bluebubbles/src/attachments.ts @@ -0,0 +1,57 @@ +import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; +import { resolveBlueBubblesAccount } from "./accounts.js"; +import { + blueBubblesFetchWithTimeout, + buildBlueBubblesApiUrl, + type BlueBubblesAttachment, +} from "./types.js"; + +export type BlueBubblesAttachmentOpts = { + serverUrl?: string; + password?: string; + accountId?: string; + timeoutMs?: number; + cfg?: ClawdbotConfig; +}; + +const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024; + +function resolveAccount(params: BlueBubblesAttachmentOpts) { + const account = resolveBlueBubblesAccount({ + cfg: params.cfg ?? {}, + accountId: params.accountId, + }); + const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim(); + const password = params.password?.trim() || account.config.password?.trim(); + if (!baseUrl) throw new Error("BlueBubbles serverUrl is required"); + if (!password) throw new Error("BlueBubbles password is required"); + return { baseUrl, password }; +} + +export async function downloadBlueBubblesAttachment( + attachment: BlueBubblesAttachment, + opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {}, +): Promise<{ buffer: Uint8Array; contentType?: string }> { + const guid = attachment.guid?.trim(); + if (!guid) throw new Error("BlueBubbles attachment guid is required"); + const { baseUrl, password } = resolveAccount(opts); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`, + password, + }); + const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, opts.timeoutMs); + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error( + `BlueBubbles attachment download failed (${res.status}): ${errorText || "unknown"}`, + ); + } + const contentType = res.headers.get("content-type") ?? undefined; + const buf = new Uint8Array(await res.arrayBuffer()); + const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES; + if (buf.byteLength > maxBytes) { + throw new Error(`BlueBubbles attachment too large (${buf.byteLength} bytes)`); + } + return { buffer: buf, contentType: contentType ?? attachment.mimeType ?? undefined }; +} diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts new file mode 100644 index 000000000..9df199158 --- /dev/null +++ b/extensions/bluebubbles/src/channel.ts @@ -0,0 +1,284 @@ +import type { ChannelAccountSnapshot, ChannelPlugin, ClawdbotConfig } from "clawdbot/plugin-sdk"; +import { + applyAccountNameToChannelSection, + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + formatPairingApproveHint, + migrateBaseNameToDefaultAccount, + normalizeAccountId, + PAIRING_APPROVED_MESSAGE, + setAccountEnabledInConfigSection, +} from "clawdbot/plugin-sdk"; + +import { + listBlueBubblesAccountIds, + type ResolvedBlueBubblesAccount, + resolveBlueBubblesAccount, + resolveDefaultBlueBubblesAccountId, +} from "./accounts.js"; +import { BlueBubblesConfigSchema } from "./config-schema.js"; +import { probeBlueBubbles } from "./probe.js"; +import { sendMessageBlueBubbles } from "./send.js"; +import { normalizeBlueBubblesHandle } from "./targets.js"; +import { bluebubblesMessageActions } from "./actions.js"; +import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js"; + +const meta = { + id: "bluebubbles", + label: "BlueBubbles", + selectionLabel: "BlueBubbles (macOS app)", + docsPath: "/channels/bluebubbles", + docsLabel: "bluebubbles", + blurb: "iMessage via the BlueBubbles mac app + REST API.", + order: 75, +}; + +export const bluebubblesPlugin: ChannelPlugin = { + id: "bluebubbles", + meta, + capabilities: { + chatTypes: ["direct", "group"], + media: false, + reactions: true, + }, + reload: { configPrefixes: ["channels.bluebubbles"] }, + configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema), + config: { + listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg as ClawdbotConfig), + resolveAccount: (cfg, accountId) => + resolveBlueBubblesAccount({ cfg: cfg as ClawdbotConfig, accountId }), + defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg as ClawdbotConfig), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg: cfg as ClawdbotConfig, + sectionKey: "bluebubbles", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg: cfg as ClawdbotConfig, + sectionKey: "bluebubbles", + accountId, + clearBaseFields: ["serverUrl", "password", "name", "webhookPath"], + }), + isConfigured: (account) => account.configured, + describeAccount: (account): ChannelAccountSnapshot => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + baseUrl: account.baseUrl, + }), + resolveAllowFrom: ({ cfg, accountId }) => + (resolveBlueBubblesAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.allowFrom ?? + []).map( + (entry) => String(entry), + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => entry.replace(/^bluebubbles:/i, "")) + .map((entry) => normalizeBlueBubblesHandle(entry)), + }, + actions: bluebubblesMessageActions, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean( + (cfg as ClawdbotConfig).channels?.bluebubbles?.accounts?.[resolvedAccountId], + ); + const basePath = useAccountPath + ? `channels.bluebubbles.accounts.${resolvedAccountId}.` + : "channels.bluebubbles."; + return { + policy: account.config.dmPolicy ?? "pairing", + allowFrom: account.config.allowFrom ?? [], + policyPath: `${basePath}dmPolicy`, + allowFromPath: basePath, + approveHint: formatPairingApproveHint("bluebubbles"), + normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")), + }; + }, + collectWarnings: ({ account }) => { + const groupPolicy = account.config.groupPolicy ?? "allowlist"; + if (groupPolicy !== "open") return []; + return [ + `- BlueBubbles groups: groupPolicy="open" allows any member to trigger the bot. Set channels.bluebubbles.groupPolicy="allowlist" + channels.bluebubbles.groupAllowFrom to restrict senders.`, + ]; + }, + }, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg: cfg as ClawdbotConfig, + channelKey: "bluebubbles", + accountId, + name, + }), + validateInput: ({ input }) => { + if (!input.httpUrl && !input.password) { + return "BlueBubbles requires --http-url and --password."; + } + if (!input.httpUrl) return "BlueBubbles requires --http-url."; + if (!input.password) return "BlueBubbles requires --password."; + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg: cfg as ClawdbotConfig, + channelKey: "bluebubbles", + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: "bluebubbles", + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + bluebubbles: { + ...next.channels?.bluebubbles, + enabled: true, + ...(input.httpUrl ? { serverUrl: input.httpUrl } : {}), + ...(input.password ? { password: input.password } : {}), + }, + }, + } as ClawdbotConfig; + } + return { + ...next, + channels: { + ...next.channels, + bluebubbles: { + ...next.channels?.bluebubbles, + enabled: true, + accounts: { + ...(next.channels?.bluebubbles?.accounts ?? {}), + [accountId]: { + ...(next.channels?.bluebubbles?.accounts?.[accountId] ?? {}), + enabled: true, + ...(input.httpUrl ? { serverUrl: input.httpUrl } : {}), + ...(input.password ? { password: input.password } : {}), + }, + }, + }, + }, + } as ClawdbotConfig; + }, + }, + pairing: { + idLabel: "bluebubblesSenderId", + normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")), + notifyApproval: async ({ cfg, id }) => { + await sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, { + cfg: cfg as ClawdbotConfig, + }); + }, + }, + outbound: { + deliveryMode: "direct", + textChunkLimit: 4000, + resolveTarget: ({ to }) => { + const trimmed = to?.trim(); + if (!trimmed) { + return { + ok: false, + error: new Error("Delivering to BlueBubbles requires --to "), + }; + } + return { ok: true, to: trimmed }; + }, + sendText: async ({ cfg, to, text, accountId }) => { + const result = await sendMessageBlueBubbles(to, text, { + cfg: cfg as ClawdbotConfig, + accountId: accountId ?? undefined, + }); + return { channel: "bluebubbles", ...result }; + }, + sendMedia: async () => { + throw new Error("BlueBubbles media delivery is not supported yet."); + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: (accounts) => + accounts.flatMap((account) => { + const lastError = typeof account.lastError === "string" ? account.lastError.trim() : ""; + if (!lastError) return []; + return [ + { + channel: "bluebubbles", + accountId: account.accountId, + kind: "runtime", + message: `Channel error: ${lastError}`, + }, + ]; + }), + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + baseUrl: snapshot.baseUrl ?? null, + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ account, timeoutMs }) => + probeBlueBubbles({ + baseUrl: account.baseUrl, + password: account.config.password ?? null, + timeoutMs, + }), + buildAccountSnapshot: ({ account, runtime, probe }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + baseUrl: account.baseUrl, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + probe, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }), + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + const webhookPath = resolveWebhookPathFromConfig(account.config); + ctx.setStatus({ + accountId: account.accountId, + baseUrl: account.baseUrl, + }); + ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`); + return monitorBlueBubblesProvider({ + account, + config: ctx.cfg as ClawdbotConfig, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }), + webhookPath, + }); + }, + }, +}; diff --git a/extensions/bluebubbles/src/chat.ts b/extensions/bluebubbles/src/chat.ts new file mode 100644 index 000000000..8896d1bba --- /dev/null +++ b/extensions/bluebubbles/src/chat.ts @@ -0,0 +1,66 @@ +import { resolveBlueBubblesAccount } from "./accounts.js"; +import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; +import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js"; + +export type BlueBubblesChatOpts = { + serverUrl?: string; + password?: string; + accountId?: string; + timeoutMs?: number; + cfg?: ClawdbotConfig; +}; + +function resolveAccount(params: BlueBubblesChatOpts) { + const account = resolveBlueBubblesAccount({ + cfg: params.cfg ?? {}, + accountId: params.accountId, + }); + const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim(); + const password = params.password?.trim() || account.config.password?.trim(); + if (!baseUrl) throw new Error("BlueBubbles serverUrl is required"); + if (!password) throw new Error("BlueBubbles password is required"); + return { baseUrl, password }; +} + +export async function markBlueBubblesChatRead( + chatGuid: string, + opts: BlueBubblesChatOpts = {}, +): Promise { + const trimmed = chatGuid.trim(); + if (!trimmed) return; + const { baseUrl, password } = resolveAccount(opts); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: `/api/v1/chat/${encodeURIComponent(trimmed)}/read`, + password, + }); + const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs); + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error(`BlueBubbles read failed (${res.status}): ${errorText || "unknown"}`); + } +} + +export async function sendBlueBubblesTyping( + chatGuid: string, + typing: boolean, + opts: BlueBubblesChatOpts = {}, +): Promise { + const trimmed = chatGuid.trim(); + if (!trimmed) return; + const { baseUrl, password } = resolveAccount(opts); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: `/api/v1/chat/${encodeURIComponent(trimmed)}/typing`, + password, + }); + const res = await blueBubblesFetchWithTimeout( + url, + { method: typing ? "POST" : "DELETE" }, + opts.timeoutMs, + ); + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error(`BlueBubbles typing failed (${res.status}): ${errorText || "unknown"}`); + } +} diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts new file mode 100644 index 000000000..a5bf8f9e3 --- /dev/null +++ b/extensions/bluebubbles/src/config-schema.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +const allowFromEntry = z.union([z.string(), z.number()]); + +const bluebubblesActionSchema = z + .object({ + reactions: z.boolean().optional(), + }) + .optional(); + +const bluebubblesAccountSchema = z.object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + serverUrl: z.string().optional(), + password: z.string().optional(), + webhookPath: z.string().optional(), + dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), + allowFrom: z.array(allowFromEntry).optional(), + groupAllowFrom: z.array(allowFromEntry).optional(), + groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(), + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + textChunkLimit: z.number().int().positive().optional(), + mediaMaxMb: z.number().int().positive().optional(), +}); + +export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({ + accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(), + actions: bluebubblesActionSchema, +}); diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts new file mode 100644 index 000000000..afc6c591a --- /dev/null +++ b/extensions/bluebubbles/src/monitor.ts @@ -0,0 +1,1105 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; + +import { enqueueSystemEvent, formatAgentEnvelope, type ClawdbotConfig } from "clawdbot/plugin-sdk"; +import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js"; +import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; +import { downloadBlueBubblesAttachment } from "./attachments.js"; +import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender, normalizeBlueBubblesHandle } from "./targets.js"; +import type { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js"; +import type { ResolvedBlueBubblesAccount } from "./accounts.js"; +import { getBlueBubblesRuntime } from "./runtime.js"; + +export type BlueBubblesRuntimeEnv = { + log?: (message: string) => void; + error?: (message: string) => void; +}; + +export type BlueBubblesMonitorOptions = { + account: ResolvedBlueBubblesAccount; + config: ClawdbotConfig; + runtime: BlueBubblesRuntimeEnv; + abortSignal: AbortSignal; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; + webhookPath?: string; +}; + +const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook"; +const DEFAULT_TEXT_LIMIT = 4000; + +type BlueBubblesCoreRuntime = ReturnType; + +function logVerbose(core: BlueBubblesCoreRuntime, runtime: BlueBubblesRuntimeEnv, message: string): void { + if (core.logging.shouldLogVerbose()) { + runtime.log?.(`[bluebubbles] ${message}`); + } +} + +type WebhookTarget = { + account: ResolvedBlueBubblesAccount; + config: ClawdbotConfig; + runtime: BlueBubblesRuntimeEnv; + core: BlueBubblesCoreRuntime; + path: string; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; +}; + +const webhookTargets = new Map(); + +function normalizeWebhookPath(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return "/"; + const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + if (withSlash.length > 1 && withSlash.endsWith("/")) { + return withSlash.slice(0, -1); + } + return withSlash; +} + +export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void { + const key = normalizeWebhookPath(target.path); + const normalizedTarget = { ...target, path: key }; + const existing = webhookTargets.get(key) ?? []; + const next = [...existing, normalizedTarget]; + webhookTargets.set(key, next); + return () => { + const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget); + if (updated.length > 0) { + webhookTargets.set(key, updated); + } else { + webhookTargets.delete(key); + } + }; +} + +async function readJsonBody(req: IncomingMessage, maxBytes: number) { + const chunks: Buffer[] = []; + let total = 0; + return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => { + req.on("data", (chunk: Buffer) => { + total += chunk.length; + if (total > maxBytes) { + resolve({ ok: false, error: "payload too large" }); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => { + try { + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw.trim()) { + resolve({ ok: false, error: "empty payload" }); + return; + } + try { + resolve({ ok: true, value: JSON.parse(raw) as unknown }); + return; + } catch { + const params = new URLSearchParams(raw); + const payload = params.get("payload") ?? params.get("data") ?? params.get("message"); + if (payload) { + resolve({ ok: true, value: JSON.parse(payload) as unknown }); + return; + } + throw new Error("invalid json"); + } + } catch (err) { + resolve({ ok: false, error: err instanceof Error ? err.message : String(err) }); + } + }); + req.on("error", (err) => { + resolve({ ok: false, error: err instanceof Error ? err.message : String(err) }); + }); + }); +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function readString(record: Record | null, key: string): string | undefined { + if (!record) return undefined; + const value = record[key]; + return typeof value === "string" ? value : undefined; +} + +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 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; +} + +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 = mimeTypes.every((entry) => entry.startsWith("audio/")); + 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})`; +} + +function buildMessagePlaceholder(message: NormalizedWebhookMessage): string { + const attachmentPlaceholder = buildAttachmentPlaceholder(message.attachments ?? []); + if (attachmentPlaceholder) return attachmentPlaceholder; + if (message.balloonBundleId) return ""; + return ""; +} + +function readNumberLike(record: Record | null, key: string): number | undefined { + if (!record) return undefined; + const value = record[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Number.parseFloat(value); + if (Number.isFinite(parsed)) return parsed; + } + return 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); +} + +type NormalizedWebhookMessage = { + text: string; + senderId: string; + senderName?: string; + messageId?: string; + timestamp?: number; + isGroup: boolean; + chatId?: number; + chatGuid?: string; + chatIdentifier?: string; + chatName?: string; + fromMe?: boolean; + attachments?: BlueBubblesAttachment[]; + balloonBundleId?: string; +}; + +type NormalizedWebhookReaction = { + action: "added" | "removed"; + emoji: string; + senderId: string; + 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" }], +]); + +function maskSecret(value: string): string { + if (value.length <= 6) return "***"; + return `${value.slice(0, 2)}***${value.slice(-2)}`; +} + +function extractMessagePayload(payload: Record): Record | null { + const dataRaw = payload.data ?? payload.payload ?? payload.event; + const data = + asRecord(dataRaw) ?? + (typeof dataRaw === "string" ? (asRecord(JSON.parse(dataRaw)) ?? null) : null); + const messageRaw = payload.message ?? data?.message ?? data; + const message = + asRecord(messageRaw) ?? + (typeof messageRaw === "string" ? (asRecord(JSON.parse(messageRaw)) ?? null) : null); + if (!message) return null; + return message; +} + +function normalizeWebhookMessage(payload: Record): NormalizedWebhookMessage | null { + const message = extractMessagePayload(payload); + if (!message) return null; + + const text = + readString(message, "text") ?? + readString(message, "body") ?? + readString(message, "subject") ?? + ""; + + const handleValue = message.handle ?? message.sender; + const handle = + asRecord(handleValue) ?? + (typeof handleValue === "string" ? { address: handleValue } : null); + const senderId = + readString(handle, "address") ?? + readString(handle, "handle") ?? + readString(handle, "id") ?? + readString(message, "senderId") ?? + readString(message, "sender") ?? + readString(message, "from") ?? + ""; + + const senderName = + readString(handle, "displayName") ?? + readString(handle, "name") ?? + readString(message, "senderName") ?? + undefined; + + const chat = asRecord(message.chat) ?? asRecord(message.conversation) ?? null; + const chatFromList = readFirstChatRecord(message); + const chatGuid = + readString(message, "chatGuid") ?? + readString(message, "chat_guid") ?? + readString(chat, "guid") ?? + readString(chatFromList, "guid"); + const chatIdentifier = + readString(message, "chatIdentifier") ?? + readString(message, "chat_identifier") ?? + readString(chat, "identifier") ?? + readString(chatFromList, "chatIdentifier") ?? + readString(chatFromList, "chat_identifier") ?? + readString(chatFromList, "identifier"); + const chatId = + readNumber(message, "chatId") ?? + readNumber(message, "chat_id") ?? + readNumber(chat, "id") ?? + readNumber(chatFromList, "id"); + const chatName = + readString(message, "chatName") ?? + readString(chat, "displayName") ?? + readString(chat, "name") ?? + readString(chatFromList, "displayName") ?? + readString(chatFromList, "name") ?? + undefined; + + const chatParticipants = chat ? chat["participants"] : undefined; + const messageParticipants = message["participants"]; + const chatsParticipants = chatFromList ? chatFromList["participants"] : undefined; + const participants = Array.isArray(chatParticipants) + ? chatParticipants + : Array.isArray(messageParticipants) + ? messageParticipants + : Array.isArray(chatsParticipants) + ? chatsParticipants + : []; + const participantsCount = participants.length; + const isGroup = + readBoolean(message, "isGroup") ?? + readBoolean(message, "is_group") ?? + readBoolean(chat, "isGroup") ?? + readBoolean(message, "group") ?? + (participantsCount > 2 ? true : false); + + 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 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; + + const normalizedSender = normalizeBlueBubblesHandle(senderId); + if (!normalizedSender) return null; + + return { + text, + senderId: normalizedSender, + senderName, + messageId, + timestamp, + isGroup, + chatId, + chatGuid, + chatIdentifier, + chatName, + fromMe, + attachments: extractAttachments(message), + balloonBundleId, + }; +} + +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 emoji = mapping?.emoji ?? `reaction:${associatedType}`; + const action = mapping?.action ?? "added"; + + const handleValue = message.handle ?? message.sender; + const handle = + asRecord(handleValue) ?? + (typeof handleValue === "string" ? { address: handleValue } : null); + const senderId = + readString(handle, "address") ?? + readString(handle, "handle") ?? + readString(handle, "id") ?? + readString(message, "senderId") ?? + readString(message, "sender") ?? + readString(message, "from") ?? + ""; + const senderName = + readString(handle, "displayName") ?? + readString(handle, "name") ?? + readString(message, "senderName") ?? + undefined; + + const chat = asRecord(message.chat) ?? asRecord(message.conversation) ?? null; + const chatFromList = readFirstChatRecord(message); + const chatGuid = + readString(message, "chatGuid") ?? + readString(message, "chat_guid") ?? + readString(chat, "guid") ?? + readString(chatFromList, "guid"); + const chatIdentifier = + readString(message, "chatIdentifier") ?? + readString(message, "chat_identifier") ?? + readString(chat, "identifier") ?? + readString(chatFromList, "chatIdentifier") ?? + readString(chatFromList, "chat_identifier") ?? + readString(chatFromList, "identifier"); + const chatId = + readNumberLike(message, "chatId") ?? + readNumberLike(message, "chat_id") ?? + readNumberLike(chat, "id") ?? + readNumberLike(chatFromList, "id"); + const chatName = + readString(message, "chatName") ?? + readString(chat, "displayName") ?? + readString(chat, "name") ?? + readString(chatFromList, "displayName") ?? + readString(chatFromList, "name") ?? + undefined; + + const chatParticipants = chat ? chat["participants"] : undefined; + const messageParticipants = message["participants"]; + const chatsParticipants = chatFromList ? chatFromList["participants"] : undefined; + const participants = Array.isArray(chatParticipants) + ? chatParticipants + : Array.isArray(messageParticipants) + ? messageParticipants + : Array.isArray(chatsParticipants) + ? chatsParticipants + : []; + const participantsCount = participants.length; + const isGroup = + readBoolean(message, "isGroup") ?? + readBoolean(message, "is_group") ?? + readBoolean(chat, "isGroup") ?? + readBoolean(message, "group") ?? + (participantsCount > 2 ? true : false); + + 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 normalizedSender = normalizeBlueBubblesHandle(senderId); + if (!normalizedSender) return null; + + return { + action, + emoji, + senderId: normalizedSender, + senderName, + messageId: associatedGuid, + timestamp, + isGroup, + chatId, + chatGuid, + chatIdentifier, + chatName, + fromMe, + }; +} + +export async function handleBlueBubblesWebhookRequest( + req: IncomingMessage, + res: ServerResponse, +): Promise { + const url = new URL(req.url ?? "/", "http://localhost"); + const path = normalizeWebhookPath(url.pathname); + const targets = webhookTargets.get(path); + if (!targets || targets.length === 0) return false; + + if (req.method !== "POST") { + res.statusCode = 405; + res.setHeader("Allow", "POST"); + res.end("Method Not Allowed"); + return true; + } + + const body = await readJsonBody(req, 1024 * 1024); + if (!body.ok) { + res.statusCode = body.error === "payload too large" ? 413 : 400; + res.end(body.error ?? "invalid payload"); + console.warn(`[bluebubbles] webhook rejected: ${body.error ?? "invalid payload"}`); + return true; + } + + const payload = asRecord(body.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); + if ( + (eventType === "updated-message" || + eventType === "message-reaction" || + eventType === "reaction") && + !reaction + ) { + res.statusCode = 200; + res.end("ok"); + if (firstTarget) { + logVerbose( + firstTarget.core, + firstTarget.runtime, + `webhook ignored ${eventType || "event"} without reaction`, + ); + } + return true; + } + const message = reaction ? null : normalizeWebhookMessage(payload); + if (!message && !reaction) { + res.statusCode = 400; + res.end("invalid payload"); + console.warn("[bluebubbles] webhook rejected: unable to parse message payload"); + return true; + } + + const matching = targets.filter((target) => { + const token = target.account.config.password?.trim(); + if (!token) return true; + 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 ?? ""; + if (guid && guid.trim() === token) return true; + const remote = req.socket?.remoteAddress ?? ""; + if (remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1") { + return true; + } + return false; + }); + + if (matching.length === 0) { + res.statusCode = 401; + res.end("unauthorized"); + console.warn( + `[bluebubbles] webhook rejected: unauthorized guid=${maskSecret(url.searchParams.get("guid") ?? url.searchParams.get("password") ?? "")}`, + ); + return true; + } + + for (const target of matching) { + 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) { + processMessage(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; +} + +async function processMessage( + message: NormalizedWebhookMessage, + target: WebhookTarget, +): Promise { + const { account, config, runtime, core, statusSink } = target; + if (message.fromMe) return; + + const text = message.text.trim(); + const attachments = message.attachments ?? []; + const placeholder = buildMessagePlaceholder(message); + if (!text && !placeholder) { + logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`); + return; + } + logVerbose( + core, + runtime, + `msg sender=${message.senderId} group=${message.isGroup} textLen=${text.length} attachments=${attachments.length} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`, + ); + + const dmPolicy = account.config.dmPolicy ?? "pairing"; + const groupPolicy = account.config.groupPolicy ?? "allowlist"; + const configAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry)); + const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry)); + const storeAllowFrom = await core.channel.pairing + .readAllowFromStore("bluebubbles") + .catch(() => []); + const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom] + .map((entry) => String(entry).trim()) + .filter(Boolean); + const effectiveGroupAllowFrom = [ + ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom), + ...storeAllowFrom, + ] + .map((entry) => String(entry).trim()) + .filter(Boolean); + + if (message.isGroup) { + if (groupPolicy === "disabled") { + logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)"); + return; + } + if (groupPolicy === "allowlist") { + if (effectiveGroupAllowFrom.length === 0) { + logVerbose(core, runtime, "Blocked BlueBubbles group message (no allowlist)"); + return; + } + const allowed = isAllowedBlueBubblesSender({ + allowFrom: effectiveGroupAllowFrom, + sender: message.senderId, + chatId: message.chatId ?? undefined, + chatGuid: message.chatGuid ?? undefined, + chatIdentifier: message.chatIdentifier ?? undefined, + }); + if (!allowed) { + 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(",")}`, + ); + return; + } + } + } else { + if (dmPolicy === "disabled") { + logVerbose(core, runtime, `Blocked BlueBubbles DM from ${message.senderId}`); + logVerbose(core, runtime, `drop: dmPolicy disabled sender=${message.senderId}`); + return; + } + if (dmPolicy !== "open") { + const allowed = isAllowedBlueBubblesSender({ + allowFrom: effectiveAllowFrom, + sender: message.senderId, + chatId: message.chatId ?? undefined, + chatGuid: message.chatGuid ?? undefined, + chatIdentifier: message.chatIdentifier ?? undefined, + }); + if (!allowed) { + if (dmPolicy === "pairing") { + const { code, created } = await core.channel.pairing.upsertPairingRequest({ + channel: "bluebubbles", + id: message.senderId, + meta: { name: message.senderName }, + }); + runtime.log?.( + `[bluebubbles] pairing request sender=${message.senderId} created=${created}`, + ); + if (created) { + logVerbose(core, runtime, `bluebubbles pairing request sender=${message.senderId}`); + try { + await sendMessageBlueBubbles( + message.senderId, + core.channel.pairing.buildPairingReply({ + channel: "bluebubbles", + idLine: `Your BlueBubbles sender id: ${message.senderId}`, + code, + }), + { cfg: config, accountId: account.accountId }, + ); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + logVerbose( + core, + runtime, + `bluebubbles pairing reply failed for ${message.senderId}: ${String(err)}`, + ); + runtime.error?.( + `[bluebubbles] pairing reply failed sender=${message.senderId}: ${String(err)}`, + ); + } + } + } else { + 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 = message.isGroup + ? chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group") + : message.senderId; + + const route = core.channel.routing.resolveAgentRoute({ + cfg: config, + channel: "bluebubbles", + accountId: account.accountId, + peer: { + kind: message.isGroup ? "group" : "dm", + id: peerId, + }, + }); + + const baseUrl = account.config.serverUrl?.trim(); + const password = account.config.password?.trim(); + 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( + downloaded.buffer, + downloaded.contentType, + "inbound", + maxBytes, + ); + mediaPaths.push(saved.path); + mediaUrls.push(saved.path); + if (saved.contentType) { + mediaTypes.push(saved.contentType); + } + } catch (err) { + logVerbose( + core, + runtime, + `attachment download failed guid=${attachment.guid} err=${String(err)}`, + ); + } + } + } + } + const rawBody = text.trim() || placeholder; + const fromLabel = message.isGroup + ? `group:${peerId}` + : message.senderName || `user:${message.senderId}`; + const body = formatAgentEnvelope({ + channel: "BlueBubbles", + from: fromLabel, + timestamp: message.timestamp, + body: rawBody, + }); + let chatGuidForActions = chatGuid; + if (!chatGuidForActions && baseUrl && password) { + const target = + message.isGroup && (chatId || chatIdentifier) + ? chatId + ? { kind: "chat_id", chatId } + : { kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" } + : { kind: "handle", address: message.senderId }; + if (target.kind !== "chat_identifier" || target.chatIdentifier) { + chatGuidForActions = + (await resolveChatGuidForTarget({ + baseUrl, + password, + target, + })) ?? undefined; + } + } + + if (chatGuidForActions && baseUrl && password) { + try { + await markBlueBubblesChatRead(chatGuidForActions, { + cfg: config, + accountId: account.accountId, + }); + logVerbose(core, runtime, `marked read chatGuid=${chatGuidForActions}`); + } catch (err) { + runtime.error?.(`[bluebubbles] mark read failed: ${String(err)}`); + } + } else { + logVerbose(core, runtime, "mark read skipped (missing chatGuid or credentials)"); + } + + const outboundTarget = message.isGroup + ? formatBlueBubblesChatTarget({ + chatId, + chatGuid: chatGuidForActions ?? chatGuid, + chatIdentifier, + }) || peerId + : chatGuidForActions + ? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions }) + : message.senderId; + + const ctxPayload = { + Body: body, + BodyForAgent: body, + RawBody: rawBody, + CommandBody: rawBody, + BodyForCommands: rawBody, + 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: message.isGroup ? `group:${peerId}` : `bluebubbles:${message.senderId}`, + To: `bluebubbles:${outboundTarget}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: message.isGroup ? "group" : "direct", + ConversationLabel: fromLabel, + SenderName: message.senderName || undefined, + SenderId: message.senderId, + Provider: "bluebubbles", + Surface: "bluebubbles", + MessageSid: message.messageId, + Timestamp: message.timestamp, + OriginatingChannel: "bluebubbles", + OriginatingTo: `bluebubbles:${outboundTarget}`, + }; + + if (chatGuidForActions && baseUrl && password) { + logVerbose(core, runtime, `typing start (pre-dispatch) chatGuid=${chatGuidForActions}`); + try { + await sendBlueBubblesTyping(chatGuidForActions, true, { + cfg: config, + accountId: account.accountId, + }); + } catch (err) { + runtime.error?.(`[bluebubbles] typing start failed: ${String(err)}`); + } + } + + try { + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg: config, + dispatcherOptions: { + deliver: async (payload) => { + const textLimit = + account.config.textChunkLimit && account.config.textChunkLimit > 0 + ? account.config.textChunkLimit + : DEFAULT_TEXT_LIMIT; + const chunks = core.channel.text.chunkMarkdownText(payload.text ?? "", textLimit); + if (!chunks.length && payload.text) chunks.push(payload.text); + if (!chunks.length) return; + for (const chunk of chunks) { + await sendMessageBlueBubbles(outboundTarget, chunk, { + cfg: config, + accountId: account.accountId, + }); + statusSink?.({ lastOutboundAt: Date.now() }); + } + }, + onReplyStart: async () => { + if (!chatGuidForActions) return; + if (!baseUrl || !password) return; + logVerbose(core, runtime, `typing start chatGuid=${chatGuidForActions}`); + try { + await sendBlueBubblesTyping(chatGuidForActions, true, { + cfg: config, + accountId: account.accountId, + }); + } catch (err) { + runtime.error?.(`[bluebubbles] typing start failed: ${String(err)}`); + } + }, + onIdle: () => { + if (!chatGuidForActions) return; + if (!baseUrl || !password) return; + logVerbose(core, runtime, `typing stop chatGuid=${chatGuidForActions}`); + void sendBlueBubblesTyping(chatGuidForActions, false, { + cfg: config, + accountId: account.accountId, + }).catch((err) => { + runtime.error?.(`[bluebubbles] typing stop failed: ${String(err)}`); + }); + }, + onError: (err, info) => { + runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`); + }, + }, + }); + } finally { + if (chatGuidForActions && baseUrl && password) { + logVerbose(core, runtime, `typing stop (finalize) chatGuid=${chatGuidForActions}`); + void sendBlueBubblesTyping(chatGuidForActions, false, { + cfg: config, + accountId: account.accountId, + }).catch((err) => { + runtime.error?.(`[bluebubbles] typing stop failed: ${String(err)}`); + }); + } + } +} + +async function processReaction( + reaction: NormalizedWebhookReaction, + target: WebhookTarget, +): Promise { + const { account, config, runtime, core } = target; + if (reaction.fromMe) return; + + const dmPolicy = account.config.dmPolicy ?? "pairing"; + const groupPolicy = account.config.groupPolicy ?? "allowlist"; + const configAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry)); + const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry)); + const storeAllowFrom = await core.channel.pairing + .readAllowFromStore("bluebubbles") + .catch(() => []); + const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom] + .map((entry) => String(entry).trim()) + .filter(Boolean); + const effectiveGroupAllowFrom = [ + ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom), + ...storeAllowFrom, + ] + .map((entry) => String(entry).trim()) + .filter(Boolean); + + if (reaction.isGroup) { + if (groupPolicy === "disabled") return; + if (groupPolicy === "allowlist") { + if (effectiveGroupAllowFrom.length === 0) return; + const allowed = isAllowedBlueBubblesSender({ + allowFrom: effectiveGroupAllowFrom, + sender: reaction.senderId, + chatId: reaction.chatId ?? undefined, + chatGuid: reaction.chatGuid ?? undefined, + chatIdentifier: reaction.chatIdentifier ?? undefined, + }); + if (!allowed) return; + } + } else { + if (dmPolicy === "disabled") return; + if (dmPolicy !== "open") { + const allowed = isAllowedBlueBubblesSender({ + allowFrom: effectiveAllowFrom, + sender: reaction.senderId, + chatId: reaction.chatId ?? undefined, + chatGuid: reaction.chatGuid ?? undefined, + chatIdentifier: reaction.chatIdentifier ?? undefined, + }); + if (!allowed) 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 route = core.channel.routing.resolveAgentRoute({ + cfg: config, + channel: "bluebubbles", + accountId: account.accountId, + peer: { + kind: reaction.isGroup ? "group" : "dm", + id: peerId, + }, + }); + + const senderLabel = reaction.senderName || reaction.senderId; + const chatLabel = reaction.isGroup ? ` in group:${peerId}` : ""; + const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${reaction.messageId}`; + enqueueSystemEvent(text, { + sessionKey: route.sessionKey, + contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`, + }); + logVerbose(core, runtime, `reaction event enqueued: ${text}`); +} + +export async function monitorBlueBubblesProvider( + options: BlueBubblesMonitorOptions, +): Promise<{ stop: () => void }> { + const { account, config, runtime, abortSignal, statusSink } = options; + const core = getBlueBubblesRuntime(); + const path = options.webhookPath?.trim() || DEFAULT_WEBHOOK_PATH; + + const unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime, + core, + path, + statusSink, + }); + + const stop = () => { + unregister(); + }; + + if (abortSignal?.aborted) { + stop(); + } else { + abortSignal?.addEventListener("abort", stop, { once: true }); + } + + runtime.log?.( + `[${account.accountId}] BlueBubbles webhook listening on ${normalizeWebhookPath(path)}`, + ); + + return { stop }; +} + +export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string { + const raw = config?.webhookPath?.trim(); + if (raw) return normalizeWebhookPath(raw); + return DEFAULT_WEBHOOK_PATH; +} diff --git a/extensions/bluebubbles/src/probe.ts b/extensions/bluebubbles/src/probe.ts new file mode 100644 index 000000000..dbbf5027b --- /dev/null +++ b/extensions/bluebubbles/src/probe.ts @@ -0,0 +1,36 @@ +import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js"; + +export type BlueBubblesProbe = { + ok: boolean; + status?: number | null; + error?: string | null; +}; + +export async function probeBlueBubbles(params: { + baseUrl?: string | null; + password?: string | null; + timeoutMs?: number; +}): Promise { + const baseUrl = params.baseUrl?.trim(); + const password = params.password?.trim(); + if (!baseUrl) return { ok: false, error: "serverUrl not configured" }; + if (!password) return { ok: false, error: "password not configured" }; + const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/ping", password }); + try { + const res = await blueBubblesFetchWithTimeout( + url, + { method: "GET" }, + params.timeoutMs, + ); + if (!res.ok) { + return { ok: false, status: res.status, error: `HTTP ${res.status}` }; + } + return { ok: true, status: res.status }; + } catch (err) { + return { + ok: false, + status: null, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/extensions/bluebubbles/src/reactions.ts b/extensions/bluebubbles/src/reactions.ts new file mode 100644 index 000000000..05819f8ca --- /dev/null +++ b/extensions/bluebubbles/src/reactions.ts @@ -0,0 +1,114 @@ +import { resolveBlueBubblesAccount } from "./accounts.js"; +import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; +import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js"; + +export type BlueBubblesReactionOpts = { + serverUrl?: string; + password?: string; + accountId?: string; + timeoutMs?: number; + cfg?: ClawdbotConfig; +}; + +const REACTION_TYPES = new Set([ + "love", + "like", + "dislike", + "laugh", + "emphasize", + "question", +]); + +const REACTION_ALIASES = new Map([ + ["heart", "love"], + ["thumbs_up", "like"], + ["thumbs-down", "dislike"], + ["thumbs_down", "dislike"], + ["haha", "laugh"], + ["lol", "laugh"], + ["emphasis", "emphasize"], + ["exclaim", "emphasize"], + ["question", "question"], +]); + +const REACTION_EMOJIS = new Map([ + ["❤️", "love"], + ["❤", "love"], + ["♥️", "love"], + ["😍", "love"], + ["👍", "like"], + ["👎", "dislike"], + ["😂", "laugh"], + ["🤣", "laugh"], + ["😆", "laugh"], + ["‼️", "emphasize"], + ["‼", "emphasize"], + ["❗", "emphasize"], + ["❓", "question"], + ["❔", "question"], +]); + +function resolveAccount(params: BlueBubblesReactionOpts) { + const account = resolveBlueBubblesAccount({ + cfg: params.cfg ?? {}, + accountId: params.accountId, + }); + const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim(); + const password = params.password?.trim() || account.config.password?.trim(); + if (!baseUrl) throw new Error("BlueBubbles serverUrl is required"); + if (!password) throw new Error("BlueBubbles password is required"); + return { baseUrl, password }; +} + +function normalizeReactionInput(emoji: string, remove?: boolean): string { + const trimmed = emoji.trim(); + if (!trimmed) throw new Error("BlueBubbles reaction requires an emoji or name."); + let raw = trimmed.toLowerCase(); + if (raw.startsWith("-")) raw = raw.slice(1); + const aliased = REACTION_ALIASES.get(raw) ?? raw; + const mapped = REACTION_EMOJIS.get(trimmed) ?? REACTION_EMOJIS.get(raw) ?? aliased; + if (!REACTION_TYPES.has(mapped)) { + throw new Error(`Unsupported BlueBubbles reaction: ${trimmed}`); + } + return remove ? `-${mapped}` : mapped; +} + +export async function sendBlueBubblesReaction(params: { + chatGuid: string; + messageGuid: string; + emoji: string; + remove?: boolean; + partIndex?: number; + opts?: BlueBubblesReactionOpts; +}): Promise { + const chatGuid = params.chatGuid.trim(); + const messageGuid = params.messageGuid.trim(); + if (!chatGuid) throw new Error("BlueBubbles reaction requires chatGuid."); + if (!messageGuid) throw new Error("BlueBubbles reaction requires messageGuid."); + const reaction = normalizeReactionInput(params.emoji, params.remove); + const { baseUrl, password } = resolveAccount(params.opts ?? {}); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: "/api/v1/message/react", + password, + }); + const payload = { + chatGuid, + selectedMessageGuid: messageGuid, + reaction, + partIndex: typeof params.partIndex === "number" ? params.partIndex : 0, + }; + const res = await blueBubblesFetchWithTimeout( + url, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + params.opts?.timeoutMs, + ); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`BlueBubbles reaction failed (${res.status}): ${errorText || "unknown"}`); + } +} diff --git a/extensions/bluebubbles/src/runtime.ts b/extensions/bluebubbles/src/runtime.ts new file mode 100644 index 000000000..cf97ba4ae --- /dev/null +++ b/extensions/bluebubbles/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setBlueBubblesRuntime(next: PluginRuntime): void { + runtime = next; +} + +export function getBlueBubblesRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("BlueBubbles runtime not initialized"); + } + return runtime; +} diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts new file mode 100644 index 000000000..0dc672d72 --- /dev/null +++ b/extensions/bluebubbles/src/send.ts @@ -0,0 +1,263 @@ +import crypto from "node:crypto"; + +import { resolveBlueBubblesAccount } from "./accounts.js"; +import { parseBlueBubblesTarget, normalizeBlueBubblesHandle } from "./targets.js"; +import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; +import { + blueBubblesFetchWithTimeout, + buildBlueBubblesApiUrl, + type BlueBubblesSendTarget, +} from "./types.js"; + +export type BlueBubblesSendOpts = { + serverUrl?: string; + password?: string; + accountId?: string; + timeoutMs?: number; + cfg?: ClawdbotConfig; +}; + +export type BlueBubblesSendResult = { + messageId: string; +}; + +function resolveSendTarget(raw: string): BlueBubblesSendTarget { + const parsed = parseBlueBubblesTarget(raw); + if (parsed.kind === "handle") { + return { + kind: "handle", + address: normalizeBlueBubblesHandle(parsed.to), + service: parsed.service, + }; + } + if (parsed.kind === "chat_id") { + return { kind: "chat_id", chatId: parsed.chatId }; + } + if (parsed.kind === "chat_guid") { + return { kind: "chat_guid", chatGuid: parsed.chatGuid }; + } + return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier }; +} + +function extractMessageId(payload: unknown): string { + if (!payload || typeof payload !== "object") return "unknown"; + const record = payload as Record; + const data = record.data && typeof record.data === "object" ? (record.data as Record) : null; + const candidates = [ + record.messageId, + record.guid, + record.id, + data?.messageId, + data?.guid, + data?.id, + ]; + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim()) return candidate.trim(); + if (typeof candidate === "number" && Number.isFinite(candidate)) return String(candidate); + } + return "unknown"; +} + +type BlueBubblesChatRecord = Record; + +function extractChatGuid(chat: BlueBubblesChatRecord): string | null { + const candidates = [ + chat.chatGuid, + chat.guid, + chat.chat_guid, + chat.identifier, + chat.chatIdentifier, + chat.chat_identifier, + ]; + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim()) return candidate.trim(); + } + return null; +} + +function extractChatId(chat: BlueBubblesChatRecord): number | null { + const candidates = [chat.chatId, chat.id, chat.chat_id]; + for (const candidate of candidates) { + if (typeof candidate === "number" && Number.isFinite(candidate)) return candidate; + } + return null; +} + +function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] { + const raw = + (Array.isArray(chat.participants) ? chat.participants : null) ?? + (Array.isArray(chat.handles) ? chat.handles : null) ?? + (Array.isArray(chat.participantHandles) ? chat.participantHandles : null); + if (!raw) return []; + const out: string[] = []; + for (const entry of raw) { + if (typeof entry === "string") { + out.push(entry); + continue; + } + if (entry && typeof entry === "object") { + const record = entry as Record; + const candidate = + (typeof record.address === "string" && record.address) || + (typeof record.handle === "string" && record.handle) || + (typeof record.id === "string" && record.id) || + (typeof record.identifier === "string" && record.identifier); + if (candidate) out.push(candidate); + } + } + return out; +} + +async function queryChats(params: { + baseUrl: string; + password: string; + timeoutMs?: number; + offset: number; + limit: number; +}): Promise { + const url = buildBlueBubblesApiUrl({ + baseUrl: params.baseUrl, + path: "/api/v1/chat/query", + password: params.password, + }); + const res = await blueBubblesFetchWithTimeout( + url, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + limit: params.limit, + offset: params.offset, + with: ["participants"], + }), + }, + params.timeoutMs, + ); + if (!res.ok) return []; + const payload = (await res.json().catch(() => null)) as Record | null; + const data = payload && typeof payload.data !== "undefined" ? (payload.data as unknown) : null; + return Array.isArray(data) ? (data as BlueBubblesChatRecord[]) : []; +} + +export async function resolveChatGuidForTarget(params: { + baseUrl: string; + password: string; + timeoutMs?: number; + target: BlueBubblesSendTarget; +}): Promise { + if (params.target.kind === "chat_guid") return params.target.chatGuid; + + const normalizedHandle = + params.target.kind === "handle" ? normalizeBlueBubblesHandle(params.target.address) : ""; + const targetChatId = params.target.kind === "chat_id" ? params.target.chatId : null; + const targetChatIdentifier = + params.target.kind === "chat_identifier" ? params.target.chatIdentifier : null; + + const limit = 500; + for (let offset = 0; offset < 5000; offset += limit) { + const chats = await queryChats({ + baseUrl: params.baseUrl, + password: params.password, + timeoutMs: params.timeoutMs, + offset, + limit, + }); + if (chats.length === 0) break; + for (const chat of chats) { + if (targetChatId != null) { + const chatId = extractChatId(chat); + if (chatId != null && chatId === targetChatId) { + return extractChatGuid(chat); + } + } + if (targetChatIdentifier) { + const guid = extractChatGuid(chat); + if (guid && guid === targetChatIdentifier) return guid; + const identifier = + typeof chat.identifier === "string" + ? chat.identifier + : typeof chat.chatIdentifier === "string" + ? chat.chatIdentifier + : typeof chat.chat_identifier === "string" + ? chat.chat_identifier + : ""; + if (identifier && identifier === targetChatIdentifier) return extractChatGuid(chat); + } + if (normalizedHandle) { + const participants = extractParticipantAddresses(chat).map((entry) => + normalizeBlueBubblesHandle(entry), + ); + if (participants.includes(normalizedHandle)) { + return extractChatGuid(chat); + } + } + } + } + return null; +} + +export async function sendMessageBlueBubbles( + to: string, + text: string, + opts: BlueBubblesSendOpts = {}, +): Promise { + const trimmedText = text ?? ""; + if (!trimmedText.trim()) { + throw new Error("BlueBubbles send requires text"); + } + + const account = resolveBlueBubblesAccount({ + cfg: opts.cfg ?? {}, + accountId: opts.accountId, + }); + const baseUrl = opts.serverUrl?.trim() || account.config.serverUrl?.trim(); + const password = opts.password?.trim() || account.config.password?.trim(); + if (!baseUrl) throw new Error("BlueBubbles serverUrl is required"); + if (!password) throw new Error("BlueBubbles password is required"); + + const target = resolveSendTarget(to); + const chatGuid = await resolveChatGuidForTarget({ + baseUrl, + password, + timeoutMs: opts.timeoutMs, + target, + }); + if (!chatGuid) { + throw new Error( + "BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.", + ); + } + const payload: Record = { + chatGuid, + tempGuid: crypto.randomUUID(), + message: trimmedText, + method: "apple-script", + }; + + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: "/api/v1/message/text", + password, + }); + const res = await blueBubblesFetchWithTimeout( + url, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + opts.timeoutMs, + ); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`); + } + const body = await res.text(); + if (!body) return { messageId: "ok" }; + try { + const parsed = JSON.parse(body) as unknown; + return { messageId: extractMessageId(parsed) }; + } catch { + return { messageId: "ok" }; + } +} diff --git a/extensions/bluebubbles/src/targets.ts b/extensions/bluebubbles/src/targets.ts new file mode 100644 index 000000000..c377c7747 --- /dev/null +++ b/extensions/bluebubbles/src/targets.ts @@ -0,0 +1,191 @@ +export type BlueBubblesService = "imessage" | "sms" | "auto"; + +export type BlueBubblesTarget = + | { kind: "chat_id"; chatId: number } + | { kind: "chat_guid"; chatGuid: string } + | { kind: "chat_identifier"; chatIdentifier: string } + | { kind: "handle"; to: string; service: BlueBubblesService }; + +export type BlueBubblesAllowTarget = + | { kind: "chat_id"; chatId: number } + | { kind: "chat_guid"; chatGuid: string } + | { kind: "chat_identifier"; chatIdentifier: string } + | { kind: "handle"; handle: string }; + +const CHAT_ID_PREFIXES = ["chat_id:", "chatid:", "chat:"]; +const CHAT_GUID_PREFIXES = ["chat_guid:", "chatguid:", "guid:"]; +const CHAT_IDENTIFIER_PREFIXES = ["chat_identifier:", "chatidentifier:", "chatident:"]; +const SERVICE_PREFIXES: Array<{ prefix: string; service: BlueBubblesService }> = [ + { prefix: "imessage:", service: "imessage" }, + { prefix: "sms:", service: "sms" }, + { prefix: "auto:", service: "auto" }, +]; + +function stripPrefix(value: string, prefix: string): string { + return value.slice(prefix.length).trim(); +} + +export function normalizeBlueBubblesHandle(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return ""; + const lowered = trimmed.toLowerCase(); + if (lowered.startsWith("imessage:")) return normalizeBlueBubblesHandle(trimmed.slice(9)); + if (lowered.startsWith("sms:")) return normalizeBlueBubblesHandle(trimmed.slice(4)); + if (lowered.startsWith("auto:")) return normalizeBlueBubblesHandle(trimmed.slice(5)); + if (trimmed.includes("@")) return trimmed.toLowerCase(); + return trimmed.replace(/\s+/g, ""); +} + +export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget { + const trimmed = raw.trim(); + if (!trimmed) throw new Error("BlueBubbles target is required"); + const lower = trimmed.toLowerCase(); + + for (const { prefix, service } of SERVICE_PREFIXES) { + if (lower.startsWith(prefix)) { + const remainder = stripPrefix(trimmed, prefix); + if (!remainder) throw new Error(`${prefix} target is required`); + const remainderLower = remainder.toLowerCase(); + const isChatTarget = + CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) || + CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) || + CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)) || + remainderLower.startsWith("group:"); + if (isChatTarget) { + return parseBlueBubblesTarget(remainder); + } + return { kind: "handle", to: remainder, service }; + } + } + + for (const prefix of CHAT_ID_PREFIXES) { + if (lower.startsWith(prefix)) { + const value = stripPrefix(trimmed, prefix); + const chatId = Number.parseInt(value, 10); + if (!Number.isFinite(chatId)) { + throw new Error(`Invalid chat_id: ${value}`); + } + return { kind: "chat_id", chatId }; + } + } + + for (const prefix of CHAT_GUID_PREFIXES) { + if (lower.startsWith(prefix)) { + const value = stripPrefix(trimmed, prefix); + if (!value) throw new Error("chat_guid is required"); + return { kind: "chat_guid", chatGuid: value }; + } + } + + for (const prefix of CHAT_IDENTIFIER_PREFIXES) { + if (lower.startsWith(prefix)) { + const value = stripPrefix(trimmed, prefix); + if (!value) throw new Error("chat_identifier is required"); + return { kind: "chat_identifier", chatIdentifier: value }; + } + } + + if (lower.startsWith("group:")) { + const value = stripPrefix(trimmed, "group:"); + const chatId = Number.parseInt(value, 10); + if (Number.isFinite(chatId)) { + return { kind: "chat_id", chatId }; + } + if (!value) throw new Error("group target is required"); + return { kind: "chat_guid", chatGuid: value }; + } + + return { kind: "handle", to: trimmed, service: "auto" }; +} + +export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget { + const trimmed = raw.trim(); + if (!trimmed) return { kind: "handle", handle: "" }; + const lower = trimmed.toLowerCase(); + + for (const { prefix } of SERVICE_PREFIXES) { + if (lower.startsWith(prefix)) { + const remainder = stripPrefix(trimmed, prefix); + if (!remainder) return { kind: "handle", handle: "" }; + return parseBlueBubblesAllowTarget(remainder); + } + } + + for (const prefix of CHAT_ID_PREFIXES) { + if (lower.startsWith(prefix)) { + const value = stripPrefix(trimmed, prefix); + const chatId = Number.parseInt(value, 10); + if (Number.isFinite(chatId)) return { kind: "chat_id", chatId }; + } + } + + for (const prefix of CHAT_GUID_PREFIXES) { + if (lower.startsWith(prefix)) { + const value = stripPrefix(trimmed, prefix); + if (value) return { kind: "chat_guid", chatGuid: value }; + } + } + + for (const prefix of CHAT_IDENTIFIER_PREFIXES) { + if (lower.startsWith(prefix)) { + const value = stripPrefix(trimmed, prefix); + if (value) return { kind: "chat_identifier", chatIdentifier: value }; + } + } + + if (lower.startsWith("group:")) { + const value = stripPrefix(trimmed, "group:"); + const chatId = Number.parseInt(value, 10); + if (Number.isFinite(chatId)) return { kind: "chat_id", chatId }; + if (value) return { kind: "chat_guid", chatGuid: value }; + } + + return { kind: "handle", handle: normalizeBlueBubblesHandle(trimmed) }; +} + +export function isAllowedBlueBubblesSender(params: { + allowFrom: Array; + sender: string; + chatId?: number | null; + chatGuid?: string | null; + chatIdentifier?: string | null; +}): boolean { + const allowFrom = params.allowFrom.map((entry) => String(entry).trim()); + if (allowFrom.length === 0) return true; + if (allowFrom.includes("*")) return true; + + const senderNormalized = normalizeBlueBubblesHandle(params.sender); + const chatId = params.chatId ?? undefined; + const chatGuid = params.chatGuid?.trim(); + const chatIdentifier = params.chatIdentifier?.trim(); + + for (const entry of allowFrom) { + if (!entry) continue; + const parsed = parseBlueBubblesAllowTarget(entry); + if (parsed.kind === "chat_id" && chatId !== undefined) { + if (parsed.chatId === chatId) return true; + } else if (parsed.kind === "chat_guid" && chatGuid) { + if (parsed.chatGuid === chatGuid) return true; + } else if (parsed.kind === "chat_identifier" && chatIdentifier) { + if (parsed.chatIdentifier === chatIdentifier) return true; + } else if (parsed.kind === "handle" && senderNormalized) { + if (parsed.handle === senderNormalized) return true; + } + } + return false; +} + +export function formatBlueBubblesChatTarget(params: { + chatId?: number | null; + chatGuid?: string | null; + chatIdentifier?: string | null; +}): string { + if (params.chatId && Number.isFinite(params.chatId)) { + return `chat_id:${params.chatId}`; + } + const guid = params.chatGuid?.trim(); + if (guid) return `chat_guid:${guid}`; + const identifier = params.chatIdentifier?.trim(); + if (identifier) return `chat_identifier:${identifier}`; + return ""; +} diff --git a/extensions/bluebubbles/src/types.ts b/extensions/bluebubbles/src/types.ts new file mode 100644 index 000000000..59746e7a7 --- /dev/null +++ b/extensions/bluebubbles/src/types.ts @@ -0,0 +1,105 @@ +export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled"; +export type GroupPolicy = "open" | "disabled" | "allowlist"; + +export type BlueBubblesAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; + /** Allow channel-initiated config writes (default: true). */ + configWrites?: boolean; + /** If false, do not start this BlueBubbles account. Default: true. */ + enabled?: boolean; + /** Base URL for the BlueBubbles API. */ + serverUrl?: string; + /** Password for BlueBubbles API authentication. */ + password?: string; + /** Webhook path for the gateway HTTP server. */ + webhookPath?: string; + /** Direct message access policy (default: pairing). */ + dmPolicy?: DmPolicy; + allowFrom?: Array; + /** Optional allowlist for group senders. */ + groupAllowFrom?: Array; + /** Group message handling policy. */ + groupPolicy?: GroupPolicy; + /** Max group messages to keep as history context (0 disables). */ + historyLimit?: number; + /** Max DM turns to keep as history context. */ + dmHistoryLimit?: number; + /** Per-DM config overrides keyed by user ID. */ + dms?: Record; + /** Outbound text chunk size (chars). Default: 4000. */ + textChunkLimit?: number; + blockStreaming?: boolean; + /** Merge streamed block replies before sending. */ + blockStreamingCoalesce?: Record; + /** Max outbound media size in MB. */ + mediaMaxMb?: number; +}; + +export type BlueBubblesActionConfig = { + reactions?: boolean; +}; + +export type BlueBubblesConfig = { + /** Optional per-account BlueBubbles configuration (multi-account). */ + accounts?: Record; + /** Per-action tool gating (default: true for all). */ + actions?: BlueBubblesActionConfig; +} & BlueBubblesAccountConfig; + +export type BlueBubblesSendTarget = + | { kind: "chat_id"; chatId: number } + | { kind: "chat_guid"; chatGuid: string } + | { kind: "chat_identifier"; chatIdentifier: string } + | { kind: "handle"; address: string; service?: "imessage" | "sms" | "auto" }; + +export type BlueBubblesAttachment = { + guid?: string; + uti?: string; + mimeType?: string; + transferName?: string; + totalBytes?: number; + height?: number; + width?: number; + originalROWID?: number; +}; + +const DEFAULT_TIMEOUT_MS = 10_000; + +export function normalizeBlueBubblesServerUrl(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + throw new Error("BlueBubbles serverUrl is required"); + } + const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; + return withScheme.replace(/\/+$/, ""); +} + +export function buildBlueBubblesApiUrl(params: { + baseUrl: string; + path: string; + password?: string; +}): string { + const normalized = normalizeBlueBubblesServerUrl(params.baseUrl); + const url = new URL(params.path, `${normalized}/`); + if (params.password) { + url.searchParams.set("password", params.password); + } + return url.toString(); +} + +export async function blueBubblesFetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs = DEFAULT_TIMEOUT_MS, +) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} diff --git a/skills/bluebubbles/SKILL.md b/skills/bluebubbles/SKILL.md new file mode 100644 index 000000000..3a71f235f --- /dev/null +++ b/skills/bluebubbles/SKILL.md @@ -0,0 +1,39 @@ +--- +name: bluebubbles +description: Build or update the BlueBubbles external channel plugin for Clawdbot (extension package, REST send/probe, webhook inbound). +--- + +# BlueBubbles plugin + +Use this skill when working on the BlueBubbles channel plugin. + +## Layout +- Extension package: `extensions/bluebubbles/` (entry: `index.ts`). +- Channel implementation: `extensions/bluebubbles/src/channel.ts`. +- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register via `api.registerHttpHandler`). +- REST helpers: `extensions/bluebubbles/src/send.ts` + `extensions/bluebubbles/src/probe.ts`. +- Runtime bridge: `extensions/bluebubbles/src/runtime.ts` (set via `api.runtime`). +- Catalog entry for onboarding: `src/channels/plugins/catalog.ts`. + +## Internal helpers (use these, not raw API calls) +- `probeBlueBubbles` in `extensions/bluebubbles/src/probe.ts` for health checks. +- `sendMessageBlueBubbles` in `extensions/bluebubbles/src/send.ts` for text delivery. +- `resolveChatGuidForTarget` in `extensions/bluebubbles/src/send.ts` for chat lookup. +- `sendBlueBubblesReaction` in `extensions/bluebubbles/src/reactions.ts` for tapbacks. +- `sendBlueBubblesTyping` + `markBlueBubblesChatRead` in `extensions/bluebubbles/src/chat.ts`. +- `downloadBlueBubblesAttachment` in `extensions/bluebubbles/src/attachments.ts` for inbound media. +- `buildBlueBubblesApiUrl` + `blueBubblesFetchWithTimeout` in `extensions/bluebubbles/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 `clawdbot/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/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index 0625161b1..a88435861 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -47,6 +47,23 @@ const CATALOG: ChannelPluginCatalogEntry[] = [ defaultChoice: "npm", }, }, + { + id: "bluebubbles", + meta: { + id: "bluebubbles", + label: "BlueBubbles", + selectionLabel: "BlueBubbles (macOS app)", + docsPath: "/channels/bluebubbles", + docsLabel: "bluebubbles", + blurb: "iMessage via the BlueBubbles mac app + REST API.", + order: 75, + }, + install: { + npmSpec: "@clawdbot/bluebubbles", + localPath: "extensions/bluebubbles", + defaultChoice: "npm", + }, + }, { id: "zalo", meta: { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 3b3f8d700..fc6b67dc6 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -54,6 +54,7 @@ export type { } from "../channels/plugins/types.js"; export type { ChannelConfigSchema, ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { ClawdbotPluginApi } from "../plugins/types.js"; +export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { ClawdbotConfig } from "../config/config.js"; export type { ChannelDock } from "../channels/dock.js"; export type { @@ -129,7 +130,7 @@ export { deleteAccountFromConfigSection, setAccountEnabledInConfigSection, } from "../channels/plugins/config-helpers.js"; -export { applyAccountNameToChannelSection } from "../channels/plugins/setup-helpers.js"; +export { applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount } from "../channels/plugins/setup-helpers.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js";