重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,45 @@
# 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 `<media:...>` 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=❤️`

View file

@ -0,0 +1,10 @@
export { bluebubblesPlugin } from "./src/channel.js";
export { bluebubblesSetupPlugin } from "./src/channel.setup.js";
export * from "./src/conversation-id.js";
export * 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";

View file

@ -0,0 +1 @@
export { BlueBubblesChannelConfigSchema } from "./src/config-schema.js";

View file

@ -0,0 +1,8 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export {
__testing as blueBubblesConversationBindingTesting,
createBlueBubblesConversationBindingManager,
} from "./src/conversation-bindings.js";

View file

@ -0,0 +1,20 @@
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: "./api.js",
exportName: "bluebubblesPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
runtime: {
specifier: "./runtime-api.js",
exportName: "setBlueBubblesRuntime",
},
});

View file

@ -0,0 +1,9 @@
{
"id": "bluebubbles",
"channels": ["bluebubbles"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View file

@ -0,0 +1,56 @@
{
"name": "@openclaw/bluebubbles",
"version": "2026.4.20",
"description": "OpenClaw BlueBubbles channel plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.4.20"
},
"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
},
"install": {
"npmSpec": "@openclaw/bluebubbles",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10"
},
"compat": {
"pluginApi": ">=2026.4.20"
},
"build": {
"openclawVersion": "2026.4.20"
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}
}

View file

@ -0,0 +1,5 @@
export {
resolveBlueBubblesGroupRequireMention,
resolveBlueBubblesGroupToolPolicy,
} from "./src/group-policy.js";
export { setBlueBubblesRuntime } from "./src/runtime.js";

View file

@ -0,0 +1,5 @@
export {
channelSecrets,
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View file

@ -0,0 +1,13 @@
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",
},
});

View file

@ -0,0 +1,82 @@
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,
});
});
});

View file

@ -0,0 +1,62 @@
import {
resolveBlueBubblesAccount,
resolveBlueBubblesEffectiveAllowPrivateNetwork,
resolveBlueBubblesPrivateNetworkConfigValue,
} from "./accounts.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { normalizeResolvedSecretInputString } from "./secret-input.js";
export 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;
} {
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");
}
return {
baseUrl,
password,
accountId: account.accountId,
allowPrivateNetwork: resolveBlueBubblesEffectiveAllowPrivateNetwork({
baseUrl,
config: account.config,
}),
allowPrivateNetworkConfig: resolveBlueBubblesPrivateNetworkConfigValue(account.config),
};
}

View file

@ -0,0 +1,107 @@
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeBlueBubblesServerUrl } from "./types.js";
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
export function normalizeBlueBubblesPrivateNetworkAliases<T extends object | undefined>(
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<T extends object | undefined>(
accounts: Record<string, T> | undefined,
): Record<string, T> | 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;
}
}

View file

@ -0,0 +1,100 @@
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-runtime";
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<string, Partial<BlueBubblesAccountConfig>>
| undefined,
);
const merged = resolveMergedAccountConfig<BlueBubblesAccountConfig>({
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);
}
export function listEnabledBlueBubblesAccounts(cfg: OpenClawConfig): ResolvedBlueBubblesAccount[] {
return listBlueBubblesAccountIds(cfg)
.map((accountId) => resolveBlueBubblesAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}

View file

@ -0,0 +1,5 @@
export { BLUEBUBBLES_ACTION_NAMES, BLUEBUBBLES_ACTIONS } from "./actions-contract.js";
export type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
} from "openclaw/plugin-sdk/channel-contract";

View file

@ -0,0 +1,19 @@
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
>;

View file

@ -0,0 +1,31 @@
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,
};

View file

@ -0,0 +1,729 @@
import { describe, expect, it, vi, beforeEach } 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 freshActionsModulePath = "./actions.js?actions-test";
const { bluebubblesMessageActions } = await import(freshActionsModulePath);
describe("bluebubblesMessageActions", () => {
const describeMessageTool = bluebubblesMessageActions.describeMessageTool!;
const supportsAction = bluebubblesMessageActions.supportsAction!;
const extractToolSend = bluebubblesMessageActions.extractToolSend!;
const handleAction = bluebubblesMessageActions.handleAction!;
const callHandleAction = (ctx: Omit<Parameters<typeof handleAction>[0], "channel">) =>
handleAction({ channel: "bluebubbles", ...ctx });
const blueBubblesConfig = (): OpenClawConfig => ({
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
});
const runReactAction = async (params: Record<string, unknown>) => {
return await callHandleAction({
action: "react",
params,
cfg: blueBubblesConfig(),
accountId: null,
});
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
});
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", { requireKnownShortId: true });
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(),
);
});
});
});

View file

@ -0,0 +1,482 @@
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 { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js";
import { normalizeSecretInputString } from "./secret-input.js";
import {
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,
};
}
function readMessageText(params: Record<string, unknown>): string | undefined {
return readStringParam(params, "text") ?? readStringParam(params, "message");
}
/** Supported action names for BlueBubbles */
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>([
...BLUEBUBBLES_ACTION_NAMES,
"upload-file",
]);
const PRIVATE_API_ACTIONS = new Set<ChannelMessageActionName>([
"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<ChannelMessageActionName>();
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<string> => {
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=<emoji> and messageId=<message_id>.",
);
}
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=<message_id>, emoji=<emoji>, and to/chatGuid to identify the chat.",
);
}
// Resolve short ID (e.g., "1", "2") to full UUID
const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, {
requireKnownShortId: true,
});
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=<message_id>, text=<new_content>.`,
);
}
// Resolve short ID (e.g., "1", "2") to full UUID
const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, {
requireKnownShortId: true,
});
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=<message_id>.",
);
}
// Resolve short ID (e.g., "1", "2") to full UUID
const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, {
requireKnownShortId: true,
});
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_id>, message=<your reply>, target=<chat_target>.`,
);
}
// Resolve short ID (e.g., "1", "2") to full UUID
const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, {
requireKnownShortId: true,
});
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=<message>, target=<chat_target>, effectId=<effect_name>.`,
);
}
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=<image_url> or path=<local_file_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}.`);
},
};

View file

@ -0,0 +1,862 @@
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 type { PluginRuntime } from "./runtime-api.js";
import { setBlueBubblesRuntime } from "./runtime.js";
import {
BLUE_BUBBLES_PRIVATE_API_STATUS,
installBlueBubblesFetchTestHooks,
mockBlueBubblesPrivateApiStatus,
mockBlueBubblesPrivateApiStatusOnce,
} from "./test-harness.js";
import type { BlueBubblesAttachment } from "./types.js";
const mockFetch = vi.fn();
const fetchServerInfoMock = vi.mocked(fetchBlueBubblesServerInfo);
const fetchRemoteMediaMock = vi.fn(
async (params: {
url: string;
maxBytes?: number;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}) => {
const fetchFn = params.fetchImpl ?? fetch;
const res = await fetchFn(params.url);
if (!res.ok) {
const text = await res.text().catch(() => "unknown");
throw new Error(
`Failed to fetch media from ${params.url}: HTTP ${res.status}; body: ${text}`,
);
}
const buffer = Buffer.from(await res.arrayBuffer());
if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) {
const error = new Error(`payload exceeds maxBytes ${params.maxBytes}`) as Error & {
code?: string;
};
error.code = "max_bytes";
throw error;
}
return {
buffer,
contentType: res.headers.get("content-type") ?? undefined,
fileName: undefined,
};
},
);
installBlueBubblesFetchTestHooks({
mockFetch,
privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
});
const runtimeStub = {
channel: {
media: {
fetchRemoteMedia:
fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
},
},
} as unknown as PluginRuntime;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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");
});
});

View file

@ -0,0 +1,303 @@
import crypto from "node:crypto";
import path from "node:path";
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";
export 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 trimmed = input?.trim() ?? "";
const base = trimmed ? path.basename(trimmed) : "";
const name = base || 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<BlueBubblesAttachment[]> {
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,
});
}
export 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<SendBlueBubblesAttachmentResult> {
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" };
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,652 @@
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<string, number>;
};
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<string, number> {
// 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<string, number> = {};
for (const [guid, count] of Object.entries(raw as Record<string, unknown>)) {
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<BlueBubblesCatchupCursor | null> {
const filePath = resolveCursorFilePath(accountId);
const { value } = await readJsonFileWithFallback<BlueBubblesCatchupCursor | null>(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<string, number>,
): Promise<void> {
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<string, number>,
maxSize: number,
): Record<string, number> {
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<string, number> = {};
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<Record<string, unknown>>;
};
export async function fetchBlueBubblesMessagesSince(
sinceMs: number,
limit: number,
opts: FetchOpts,
): Promise<BlueBubblesCatchupFetchResult> {
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<Record<string, unknown>> = [];
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<string, Promise<BlueBubblesCatchupSummary | null>>();
export function runBlueBubblesCatchup(
target: WebhookTarget,
deps: RunBlueBubblesCatchupDeps = {},
): Promise<BlueBubblesCatchupSummary | null> {
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<BlueBubblesCatchupSummary | null> {
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<string, number> = {};
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;
}

View file

@ -0,0 +1,68 @@
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<ResolvedBlueBubblesAccount>["capabilities"] = {
chatTypes: ["direct", "group"],
media: true,
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<ResolvedBlueBubblesAccount>({
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,
},
});
}

View file

@ -0,0 +1,48 @@
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",
}),
);
});
});

View file

@ -0,0 +1,19 @@
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,
};

View file

@ -0,0 +1,31 @@
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<ResolvedBlueBubblesAccount> = {
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,
};

View file

@ -0,0 +1,80 @@
import { 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,
},
}));
vi.mock("../../../src/channels/plugins/bundled.js", () => ({
bundledChannelPlugins: [],
bundledChannelSetupPlugins: [],
}));
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,
});
});
});

View file

@ -0,0 +1,356 @@
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 {
createOpenGroupPolicyRestrictSendersWarningCollector,
projectAccountWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import { buildProbeChannelStatusSummary } from "openclaw/plugin-sdk/channel-status";
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 {
extractHandleFromChatGuid,
inferBlueBubblesTargetChatType,
looksLikeBlueBubblesExplicitTargetId,
looksLikeBlueBubblesTargetId,
normalizeBlueBubblesHandle,
normalizeBlueBubblesMessagingTarget,
parseBlueBubblesTarget,
} from "./targets.js";
const loadBlueBubblesChannelRuntime = createLazyRuntimeNamedExport(
() => import("./channel.runtime.js"),
"blueBubblesChannelRuntime",
);
const resolveBlueBubblesDmPolicy = createScopedDmSecurityResolver<ResolvedBlueBubblesAccount>({
channelKey: "bluebubbles",
resolvePolicy: (account) => account.config.dmPolicy,
resolveAllowFrom: (account) => account.config.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")),
});
const collectBlueBubblesSecurityWarnings =
createOpenGroupPolicyRestrictSendersWarningCollector<ResolvedBlueBubblesAccount>({
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<ResolvedBlueBubblesAccount, BlueBubblesProbe> =
createChatChannelPlugin<ResolvedBlueBubblesAccount, BlueBubblesProbe>({
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: {
normalizeTarget: normalizeBlueBubblesMessagingTarget,
inferTargetChatType: ({ to }) => inferBlueBubblesTargetChatType(to),
resolveOutboundSessionRoute: (params) => resolveBlueBubblesOutboundSessionRoute(params),
targetResolver: {
looksLikeId: looksLikeBlueBubblesExplicitTargetId,
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
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<ResolvedBlueBubblesAccount, BlueBubblesProbe>({
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();
}
},
},
},
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 <handle|chat_guid:GUID>"),
};
}
return { ok: true, to: trimmed };
},
},
attachedResults: {
channel: "bluebubbles",
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
const runtime = await loadBlueBubblesChannelRuntime();
const rawReplyToId = normalizeOptionalString(replyToId) ?? "";
const replyToMessageGuid = rawReplyToId
? runtime.resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
: "";
return await runtime.sendMessageBlueBubbles(to, text, {
cfg: cfg,
accountId: accountId ?? undefined,
replyToMessageGuid: replyToMessageGuid || undefined,
});
},
sendMedia: async (ctx) => {
const runtime = await loadBlueBubblesChannelRuntime();
const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx;
const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as {
mediaPath?: string;
mediaBuffer?: Uint8Array;
contentType?: string;
filename?: string;
caption?: string;
};
return await runtime.sendBlueBubblesMedia({
cfg: cfg,
to,
mediaUrl,
mediaPath,
mediaBuffer,
contentType,
filename,
caption: caption ?? text ?? undefined,
replyToId: replyToId ?? null,
accountId: accountId ?? undefined,
});
},
},
},
});

View file

@ -0,0 +1,621 @@
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<void>;
}) {
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<void>;
}) {
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");
});
});
});

View file

@ -0,0 +1,305 @@
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<void> {
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<void> {
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<void> {
await sendBlueBubblesChatEndpointRequest({
chatGuid,
opts,
endpoint: "read",
method: "POST",
action: "read",
});
}
export async function sendBlueBubblesTyping(
chatGuid: string,
typing: boolean,
opts: BlueBubblesChatOpts = {},
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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");
}

View file

@ -0,0 +1,633 @@
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
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 type { PluginRuntime } from "./runtime-api.js";
import { setBlueBubblesRuntime } from "./runtime.js";
import {
createBlueBubblesFetchGuardPassthroughInstaller,
installBlueBubblesFetchTestHooks,
} from "./test-harness.js";
import type { BlueBubblesAttachment } from "./types.js";
import { _setFetchGuardForTesting } from "./types.js";
// --- Test infrastructure ---------------------------------------------------
const mockFetch = vi.fn();
const fetchRemoteMediaMock = vi.fn(
async (params: {
url: string;
maxBytes?: number;
ssrfPolicy?: SsrFPolicy;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}) => {
const fetchFn = params.fetchImpl ?? fetch;
const res = await fetchFn(params.url);
if (!res.ok) {
throw new Error(`media fetch failed: HTTP ${res.status}`);
}
const buffer = Buffer.from(await res.arrayBuffer());
if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) {
const error = new Error(`payload exceeds maxBytes ${params.maxBytes}`) as Error & {
code?: string;
};
error.code = "max_bytes";
throw error;
}
return {
buffer,
contentType: res.headers.get("content-type") ?? undefined,
fileName: undefined,
};
},
);
installBlueBubblesFetchTestHooks({
mockFetch,
privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
});
const runtimeStub = {
channel: {
media: {
fetchRemoteMedia:
fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
},
},
} as unknown as PluginRuntime;
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<string, unknown>;
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;

View file

@ -0,0 +1,582 @@
// 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 { 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 { DEFAULT_ACCOUNT_ID } from "./runtime-api.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.
*/
export 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 ----------------------------------------------------------------
export 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<string, string>;
timeoutMs?: number;
}): Promise<Response> {
const init: RequestInit = {};
if (params.headers) {
init.headers = { ...params.headers };
}
if (params.body !== undefined) {
init.headers = {
"Content-Type": "application/json",
...(init.headers as Record<string, string> | 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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<BlueBubblesAttachment[]> {
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<string, unknown>);
}
/**
* 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<string, CachedClientEntry>();
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),
});
}

View file

@ -0,0 +1,84 @@
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
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,
},
},
},
},
};
}

View file

@ -0,0 +1,111 @@
import {
AllowFromListSchema,
buildChannelConfigSchema,
buildCatchallMultiAccountChannelSchema,
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
ToolPolicySchema,
} 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,
});
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(),
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(),
groups: z.object({}).catchall(bluebubblesGroupConfigSchema).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",
});
}
});
export const BlueBubblesConfigSchema = buildCatchallMultiAccountChannelSchema(
bluebubblesAccountSchema,
).safeExtend({
actions: bluebubblesActionSchema,
});
export const BlueBubblesChannelConfigSchema = buildChannelConfigSchema(BlueBubblesConfigSchema, {
uiHints: bluebubblesChannelConfigUiHints,
});

View file

@ -0,0 +1,12 @@
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<string, ChannelConfigUiHint>;

View file

@ -0,0 +1,64 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
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",
}),
});
});
});

View file

@ -0,0 +1,46 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
createAccountScopedConversationBindingManager,
resetAccountScopedConversationBindingsForTests,
type AccountScopedConversationBindingManager,
type BindingTargetKind,
} from "openclaw/plugin-sdk/thread-bindings-runtime";
type BlueBubblesBindingTargetKind = "subagent" | "acp";
type BlueBubblesConversationBindingManager =
AccountScopedConversationBindingManager<BlueBubblesBindingTargetKind>;
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,
});
},
};

View file

@ -0,0 +1,77 @@
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;
}

View file

@ -0,0 +1,63 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
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);
});
});

View file

@ -0,0 +1,83 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
getSessionBindingService,
isPluginOwnedSessionBindingRecord,
resolveConfiguredBindingRoute,
} from "openclaw/plugin-sdk/conversation-runtime";
import {
deriveLastRoutePolicy,
resolveAgentIdFromSessionKey,
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<typeof resolveAgentRoute> {
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 runtimeBinding = getSessionBindingService().resolveByConversation({
channel: "bluebubbles",
accountId: params.accountId,
conversationId,
});
const boundSessionKey = runtimeBinding?.targetSessionKey?.trim();
if (!runtimeBinding || !boundSessionKey) {
return route;
}
getSessionBindingService().touch(runtimeBinding.bindingId);
if (isPluginOwnedSessionBindingRecord(runtimeBinding)) {
logVerbose(`bluebubbles: plugin-bound conversation ${conversationId}`);
return route;
}
logVerbose(`bluebubbles: routed via bound conversation ${conversationId} -> ${boundSessionKey}`);
return {
...route,
sessionKey: boundSessionKey,
agentId: resolveAgentIdFromSessionKey(boundSessionKey),
lastRoutePolicy: deriveLastRoutePolicy({
sessionKey: boundSessionKey,
mainSessionKey: route.mainSessionKey,
}),
matchedBy: "binding.channel",
};
}

View file

@ -0,0 +1,9 @@
import { createLegacyPrivateNetworkDoctorContract } from "openclaw/plugin-sdk/ssrf-runtime";
const contract = createLegacyPrivateNetworkDoctorContract({
channelKey: "bluebubbles",
});
export const legacyConfigRules = contract.legacyConfigRules;
export const normalizeCompatibilityConfig = contract.normalizeCompatibilityConfig;

View file

@ -0,0 +1,40 @@
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,
});
});
});

View file

@ -0,0 +1,10 @@
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,
};

View file

@ -0,0 +1,40 @@
import {
resolveChannelGroupRequireMention,
resolveChannelGroupToolsPolicy,
type GroupToolPolicyConfig,
} from "openclaw/plugin-sdk/channel-policy";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
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,
});
}

View file

@ -0,0 +1,183 @@
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { createBlueBubblesClientFromParts } from "./client.js";
import type { OpenClawConfig } from "./runtime-api.js";
export type BlueBubblesHistoryEntry = {
sender: string;
body: string;
timestamp?: number;
messageId?: string;
};
export 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;
};
export 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;
};
};
export 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<BlueBubblesHistoryFetchResult> {
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 };
}

View file

@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
_resetBlueBubblesInboundDedupForTest,
claimBlueBubblesInboundMessage,
resolveBlueBubblesInboundDedupeKey,
} from "./inbound-dedupe.js";
async function claimAndFinalize(guid: string | undefined, accountId: string): Promise<string> {
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("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();
});
});

View file

@ -0,0 +1,232 @@
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<string>();
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;
}
export type InboundDedupeClaim =
| { kind: "claimed"; finalize: () => Promise<void>; 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<InboundDedupeClaim> {
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 });
},
};
}
/**
* 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<void> {
// 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();
}

View file

@ -0,0 +1,62 @@
import path from "node:path";
import { fileURLToPath, URL } from "node:url";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
function isLocalFileUrlHost(hostname: string): boolean {
const normalized = normalizeLowercaseStringOrEmpty(hostname);
return normalized === "" || normalized === "localhost";
}
function assertNoWindowsNetworkPath(filePath: string, label = "Path"): void {
if (process.platform !== "win32") {
return;
}
const normalized = filePath.replace(/\//g, "\\");
if (normalized.startsWith("\\\\?\\UNC\\") || normalized.startsWith("\\\\")) {
throw new Error(`${label} cannot use Windows network paths: ${filePath}`);
}
}
export function safeFileURLToPath(fileUrl: string): string {
let parsed: URL;
try {
parsed = new URL(fileUrl);
} catch {
throw new Error(`Invalid file:// URL: ${fileUrl}`);
}
if (parsed.protocol !== "file:") {
throw new Error(`Invalid file:// URL: ${fileUrl}`);
}
if (!isLocalFileUrlHost(parsed.hostname)) {
throw new Error(`file:// URLs with remote hosts are not allowed: ${fileUrl}`);
}
const filePath = fileURLToPath(parsed);
assertNoWindowsNetworkPath(filePath, "Local file URL");
return filePath;
}
function trySafeFileURLToPath(fileUrl: string): string | undefined {
try {
return safeFileURLToPath(fileUrl);
} catch {
return undefined;
}
}
export function basenameFromMediaSource(source?: string): string | undefined {
if (!source) {
return undefined;
}
if (source.startsWith("file://")) {
const filePath = trySafeFileURLToPath(source);
return filePath ? path.basename(filePath) || undefined : undefined;
}
if (/^https?:\/\//i.test(source)) {
try {
return path.basename(new URL(source).pathname) || undefined;
} catch {
return undefined;
}
}
return path.basename(source) || undefined;
}

View file

@ -0,0 +1,326 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { 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,
}));
type RuntimeMocks = {
detectMime: ReturnType<typeof vi.fn>;
fetchRemoteMedia: ReturnType<typeof vi.fn>;
};
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<string, unknown>): OpenClawConfig {
return {
channels: {
bluebubbles: {
...overrides,
},
},
} as unknown as OpenClawConfig;
}
async function makeTempDir(): Promise<string> {
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<string, unknown>;
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);
});
});

View file

@ -0,0 +1,296 @@
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";
import { lowercasePreservingWhitespace } from "openclaw/plugin-sdk/text-runtime";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { sendBlueBubblesAttachment } from "./attachments.js";
import { basenameFromMediaSource, safeFileURLToPath } from "./local-file-access.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";
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 resolveLocalMediaPath(source: string): string {
if (!source.startsWith("file://")) {
return source;
}
try {
return safeFileURLToPath(source);
} catch {
throw new Error(`Invalid file:// URL: ${source}`);
}
}
function expandHomePath(input: string): string {
if (input === "~") {
return os.homedir();
}
if (input.startsWith("~/") || input.startsWith(`~${path.sep}`)) {
return path.join(os.homedir(), input.slice(2));
}
return input;
}
function resolveConfiguredPath(input: string): string {
const trimmed = input.trim();
if (!trimmed) {
throw new Error("Empty mediaLocalRoots entry is not allowed");
}
if (trimmed.startsWith("file://")) {
try {
return safeFileURLToPath(trimmed);
} catch {
throw new Error(`Invalid file:// URL in mediaLocalRoots: ${input}`);
}
}
const resolved = expandHomePath(trimmed);
if (!path.isAbsolute(resolved)) {
throw new Error(`mediaLocalRoots entries must be absolute paths: ${input}`);
}
return resolved;
}
function isPathInsideRoot(candidate: string, root: string): boolean {
const normalizedCandidate = path.normalize(candidate);
const normalizedRoot = path.normalize(root);
const rootWithSep = normalizedRoot.endsWith(path.sep)
? normalizedRoot
: normalizedRoot + path.sep;
if (process.platform === "win32") {
const candidateLower = lowercasePreservingWhitespace(normalizedCandidate);
const rootLower = lowercasePreservingWhitespace(normalizedRoot);
const rootWithSepLower = lowercasePreservingWhitespace(rootWithSep);
return candidateLower === rootLower || candidateLower.startsWith(rootWithSepLower);
}
return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(rootWithSep);
}
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 resolvedLocalPath = path.resolve(params.localPath);
const supportsNoFollow = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
const openFlags = fsConstants.O_RDONLY | (supportsNoFollow ? fsConstants.O_NOFOLLOW : 0);
for (const rootEntry of params.localRoots) {
const resolvedRootInput = resolveConfiguredPath(rootEntry);
const relativeToRoot = path.relative(resolvedRootInput, resolvedLocalPath);
if (
relativeToRoot.startsWith("..") ||
path.isAbsolute(relativeToRoot) ||
relativeToRoot === ""
) {
continue;
}
let rootReal: string;
try {
rootReal = await fs.realpath(resolvedRootInput);
} catch {
rootReal = path.resolve(resolvedRootInput);
}
const candidatePath = path.resolve(rootReal, relativeToRoot);
if (!isPathInsideRoot(candidatePath, rootReal)) {
continue;
}
let handle: Awaited<ReturnType<typeof fs.open>> | null = null;
try {
handle = await fs.open(candidatePath, openFlags);
const realPath = await fs.realpath(candidatePath);
if (!isPathInsideRoot(realPath, rootReal)) {
continue;
}
const stat = await handle.stat();
if (!stat.isFile()) {
continue;
}
const realStat = await fs.stat(realPath);
if (stat.ino !== realStat.ino || stat.dev !== realStat.dev) {
continue;
}
const data = await handle.readFile();
return { data, realPath, sizeBytes: stat.size };
} catch {
// Try next configured root.
continue;
} finally {
if (handle) {
await handle.close().catch(() => {});
}
}
}
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 localPath = expandHomePath(resolveLocalMediaPath(source));
const localFile = await assertLocalMediaPathAllowed({
localPath,
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
const replyToMessageGuid = replyToId?.trim()
? resolveBlueBubblesMessageId(replyToId.trim(), { requireKnownShortId: true })
: 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;
}

View file

@ -0,0 +1,228 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-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: "",
},
};
}
export type BlueBubblesDebouncer = {
enqueue: (item: BlueBubblesDebounceEntry) => Promise<void>;
flushKey: (key: string) => Promise<void>;
};
export 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;
/**
* 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;
// Combine text from all entries, filtering out duplicates and empty strings
const seenTexts = new Set<string>();
const textParts: string[] = [];
for (const entry of entries) {
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);
}
// Merge attachments from all entries
const allAttachments = entries.flatMap((e) => e.message.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));
// Prefer reply context from any entry that has it
const entryWithReply = entries.find((e) => e.message.replyToId);
return {
...first,
text: textParts.join(" "),
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,
// 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,
): number {
const inbound = config.messages?.inbound;
const hasExplicitDebounce =
typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number";
if (!hasExplicitDebounce) {
return DEFAULT_INBOUND_DEBOUNCE_MS;
}
return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" });
}
export function createBlueBubblesDebounceRegistry(params: {
processMessage: (message: NormalizedWebhookMessage, target: WebhookTarget) => Promise<void>;
}): BlueBubblesDebounceRegistry {
const targetDebouncers = new Map<WebhookTarget, BlueBubblesDebouncer>();
return {
getOrCreateDebouncer: (target) => {
const existing = targetDebouncers.get(target);
if (existing) {
return existing;
}
const { account, config, runtime, core } = target;
const baseDebouncer = core.channel.debounce.createInboundDebouncer<BlueBubblesDebounceEntry>({
debounceMs: resolveBlueBubblesDebounceMs(config, core),
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}`;
}
const messageId = msg.messageId?.trim();
if (messageId) {
return `bluebubbles:${account.accountId}:msg:${messageId}`;
}
const chatKey =
msg.chatGuid?.trim() ??
msg.chatIdentifier?.trim() ??
(msg.chatId ? String(msg.chatId) : "dm");
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;
}
// Skip debouncing for control commands - process immediately
if (core.channel.text.hasControlCommand(msg.text, config)) {
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);
},
};
}

View file

@ -0,0 +1,142 @@
import { describe, expect, it } from "vitest";
import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
function createFallbackDmPayload(overrides: Record<string, unknown> = {}) {
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");
});
});

View file

@ -0,0 +1,849 @@
import { parseFiniteNumber } from "openclaw/plugin-sdk/infra-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<string, unknown> | 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<string, unknown> | null, key: string): boolean | undefined {
if (!record) {
return undefined;
}
const value = record[key];
return typeof value === "boolean" ? value : undefined;
}
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
if (!record) {
return undefined;
}
return parseFiniteNumber(record[key]);
}
export function extractAttachments(message: Record<string, unknown>): 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
? "<media:image>"
: allVideos
? "<media:video>"
: allAudio
? "<media:audio>"
: "<media:attachment>";
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 "<media:sticker>";
}
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<string, unknown>): {
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<string, unknown>): Record<string, unknown> | 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<string, unknown> | 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<string, unknown>): {
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<string, unknown>): {
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<string>();
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<string>();
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;
};
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<number, { emoji: string; action: "added" | "removed" }>([
[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<string, { emoji: string; action: "added" | "removed" }>([
["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<string, unknown>): Record<string, unknown> | null {
const parseRecord = (value: unknown): Record<string, unknown> | 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<string, unknown>,
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<string, unknown>,
): 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,
};
}

View file

@ -0,0 +1,20 @@
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 { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
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/config-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";

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,182 @@
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<string, BlueBubblesReplyCacheEntry>();
// Bidirectional maps for short ID ↔ message GUID resolution (token savings optimization)
const blueBubblesShortIdToUuid = new Map<string, string>();
const blueBubblesUuidToShortId = new Map<string, string>();
let blueBubblesShortIdCounter = 0;
function generateShortId(): string {
blueBubblesShortIdCounter += 1;
return String(blueBubblesShortIdCounter);
}
export function rememberBlueBubblesReplyCache(
entry: Omit<BlueBubblesReplyCacheEntry, "shortId">,
): 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;
}
/**
* 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.
*/
export function resolveBlueBubblesMessageId(
shortOrUuid: string,
opts?: { requireKnownShortId?: boolean },
): 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)) {
const uuid = blueBubblesShortIdToUuid.get(trimmed);
if (uuid) {
return uuid;
}
if (opts?.requireKnownShortId) {
throw new Error(
`BlueBubbles short message id "${trimmed}" is no longer available. Use MessageSidFull.`,
);
}
}
// Return as-is (either already a UUID or not found)
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;
}

View file

@ -0,0 +1,190 @@
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);
});
});

View file

@ -0,0 +1,123 @@
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<string, number>();
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;
}

View file

@ -0,0 +1,33 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
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<typeof getBlueBubblesRuntime>;
export type WebhookTarget = {
account: ResolvedBlueBubblesAccount;
config: OpenClawConfig;
runtime: BlueBubblesRuntimeEnv;
core: BlueBubblesCoreRuntime;
path: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,396 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { safeEqualSecret } from "openclaw/plugin-sdk/browser-security-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { resolveBlueBubblesEffectiveAllowPrivateNetwork } from "./accounts.js";
import { runBlueBubblesCatchup } from "./catchup.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 {
WEBHOOK_RATE_LIMIT_DEFAULTS,
createFixedWindowRateLimiter,
createWebhookInFlightLimiter,
registerWebhookTargetWithPluginRoute,
readWebhookBodyOrReject,
resolveRequestClientIp,
resolveWebhookTargetWithAuthOrRejectSync,
withResolvedWebhookRequestPipeline,
} from "./webhook-ingress.js";
const webhookTargets = new Map<string, WebhookTarget[]>();
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<string>();
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<boolean> {
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 = target.account.config.password?.trim() ?? "";
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<void> {
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.
runBlueBubblesCatchup(target).catch((err) => {
runtime.error?.(
`[${account.accountId}] BlueBubbles catchup: unexpected failure: ${String(err)}`,
);
});
});
}
export { _resetBlueBubblesShortIdState, resolveBlueBubblesMessageId, resolveWebhookPathFromConfig };

View file

@ -0,0 +1,681 @@
import { 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<typeof import("./webhook-ingress.js")>("./webhook-ingress.js");
return {
...actual,
WEBHOOK_RATE_LIMIT_DEFAULTS: {
...actual.WEBHOOK_RATE_LIMIT_DEFAULTS,
maxRequests: TEST_WEBHOOK_RATE_LIMIT_MAX_REQUESTS,
},
readWebhookBodyOrReject: (params: Parameters<typeof actual.readWebhookBodyOrReject>[0]) =>
actual.readWebhookBodyOrReject({
...params,
timeoutMs: TEST_WEBHOOK_BODY_TIMEOUT_MS,
}),
};
});
// 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<WebhookRequestParams, "remoteAddress">,
) {
return expectWebhookRequestStatusForTest(
createLoopbackWebhookRequestParamsForTest(remoteAddress, { overrides }),
expectedStatus,
);
}
async function expectProtectedLoopbackWebhookRequestStatus(
remoteAddress: (typeof LOOPBACK_REMOTE_ADDRESSES_FOR_TEST)[number],
expectedStatus: number,
overrides?: Omit<WebhookRequestParams, "remoteAddress">,
) {
setupProtectedWebhookTarget();
return expectLoopbackWebhookRequestStatus(remoteAddress, expectedStatus, overrides);
}
async function expectPasswordlessLoopbackWebhookRequestStatus(
remoteAddress: (typeof LOOPBACK_REMOTE_ADDRESSES_FOR_TEST)[number],
expectedStatus: number,
overrides?: Omit<WebhookRequestParams, "remoteAddress">,
) {
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("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<string, string>[] = [
{ 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),
);
});
});
});

View file

@ -0,0 +1,374 @@
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<string, string>;
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["config"]> = {},
): 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<string, unknown> = {}) {
return {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
...dataOverrides,
},
};
}
export function createTimestampedNewMessagePayloadForTest(
dataOverrides: Record<string, unknown> = {},
) {
return createNewMessagePayloadForTest({
...dataOverrides,
date: Date.now(),
});
}
export function createMessageReactionPayloadForTest(dataOverrides: Record<string, unknown> = {}) {
return {
type: "message-reaction",
data: {
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
associatedMessageGuid: "msg-original-123",
associatedMessageType: 2000,
...dataOverrides,
},
};
}
export function createTimestampedMessageReactionPayloadForTest(
dataOverrides: Record<string, unknown> = {},
) {
return createMessageReactionPayloadForTest({
...dataOverrides,
date: Date.now(),
});
}
export function createMockRequest(
method: string,
url: string,
body: unknown,
headers: Record<string, string> = {},
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;
}
export 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, "url">;
} = {},
): 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, "remoteAddress">;
} = {},
): 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 };
}
export 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;
}
export async function flushAsync() {
for (let i = 0; i < 2; i += 1) {
await new Promise<void>((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<T extends { unregister: () => void }>(
registration: T,
setUnregister: (unregister: () => void) => void,
) {
setUnregister(registration.unregister);
return registration;
}
export 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,
});
}
export 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 };
}

View file

@ -0,0 +1,57 @@
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { blueBubblesFetchWithTimeout } from "./types.js";
export 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<Response> {
const body = Buffer.from(concatUint8Arrays(params.parts));
const headers: Record<string, string> = {};
if (params.extraHeaders) {
new Headers(params.extraHeaders).forEach((value, key) => {
headers[key] = value;
});
}
// Per-request Content-Type wins over callers so the multipart boundary is
// always authoritative.
headers["Content-Type"] = `multipart/form-data; boundary=${params.boundary}`;
return await blueBubblesFetchWithTimeout(
params.url,
{
method: "POST",
headers,
body,
},
params.timeoutMs,
params.ssrfPolicy,
);
}
export async function assertMultipartActionOk(response: Response, action: string): Promise<void> {
if (response.ok) {
return;
}
const errorText = await response.text().catch(() => "");
throw new Error(`BlueBubbles ${action} failed (${response.status}): ${errorText || "unknown"}`);
}

View file

@ -0,0 +1,37 @@
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
import type { OpenClawConfig } from "./runtime-api.js";
import { normalizeBlueBubblesHandle } from "./targets.js";
type SendBlueBubblesMessage = (
id: string,
message: string,
params: {
cfg: OpenClawConfig;
accountId?: string;
},
) => Promise<unknown>;
export function createBlueBubblesPairingText(sendMessageBlueBubbles: SendBlueBubblesMessage) {
return {
idLabel: "bluebubblesSenderId",
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: createPairingPrefixStripper(/^bluebubbles:/i, normalizeBlueBubblesHandle),
notify: async ({
cfg,
id,
message,
accountId,
}: {
cfg: OpenClawConfig;
id: string;
message: string;
accountId?: string;
}) => {
await sendMessageBlueBubbles(id, message, {
cfg,
accountId,
});
},
};
}

View file

@ -0,0 +1,193 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
enrichBlueBubblesParticipantsWithContactNames,
listBlueBubblesContactsDatabasesForTest,
queryBlueBubblesContactsDatabaseForTest,
resetBlueBubblesParticipantContactNameCacheForTest,
resolveBlueBubblesParticipantContactNamesFromMacOsContactsForTest,
} from "./participant-contact-names.js";
describe("enrichBlueBubblesParticipantsWithContactNames", () => {
beforeEach(() => {
resetBlueBubblesParticipantContactNameCacheForTest();
});
it("enriches unnamed phone participants and reuses cached names across formats", async () => {
const resolver = vi.fn(
async (phoneKeys: string[]) =>
new Map(
phoneKeys.map((phoneKey) => [
phoneKey,
phoneKey === "5551234567" ? "Alice Example" : "Bob Example",
]),
),
);
const first = await enrichBlueBubblesParticipantsWithContactNames(
[{ id: "+1 (555) 123-4567" }, { id: "+15557654321" }],
{
platform: "darwin",
now: () => 1_000,
resolvePhoneNames: resolver,
},
);
expect(first).toEqual([
{ id: "+1 (555) 123-4567", name: "Alice Example" },
{ id: "+15557654321", name: "Bob Example" },
]);
expect(resolver).toHaveBeenCalledTimes(1);
expect(resolver).toHaveBeenCalledWith(["5551234567", "5557654321"]);
const secondResolver = vi.fn(async () => new Map<string, string>());
const second = await enrichBlueBubblesParticipantsWithContactNames([{ id: "+15551234567" }], {
platform: "darwin",
now: () => 2_000,
resolvePhoneNames: secondResolver,
});
expect(second).toEqual([{ id: "+15551234567", name: "Alice Example" }]);
expect(secondResolver).not.toHaveBeenCalled();
});
it("retries negative cache entries after the short negative ttl expires", async () => {
const firstResolver = vi.fn(async () => new Map<string, string>());
const secondResolver = vi.fn(async () => new Map([["5551234567", "Alice Example"]]));
const first = await enrichBlueBubblesParticipantsWithContactNames([{ id: "+15551234567" }], {
platform: "darwin",
now: () => 1_000,
resolvePhoneNames: firstResolver,
});
const second = await enrichBlueBubblesParticipantsWithContactNames([{ id: "+15551234567" }], {
platform: "darwin",
now: () => 1_500,
resolvePhoneNames: secondResolver,
});
const third = await enrichBlueBubblesParticipantsWithContactNames([{ id: "+15551234567" }], {
platform: "darwin",
now: () => 1_000 + 6 * 60 * 1000,
resolvePhoneNames: secondResolver,
});
expect(first).toEqual([{ id: "+15551234567" }]);
expect(second).toEqual([{ id: "+15551234567" }]);
expect(third).toEqual([{ id: "+15551234567", name: "Alice Example" }]);
expect(firstResolver).toHaveBeenCalledTimes(1);
expect(secondResolver).toHaveBeenCalledTimes(1);
});
it("skips email addresses and keeps existing participant names", async () => {
const resolver = vi.fn(async () => new Map<string, string>());
const participants = await enrichBlueBubblesParticipantsWithContactNames(
[{ id: "alice@example.com" }, { id: "+15551234567", name: "Alice Existing" }],
{
platform: "darwin",
now: () => 1_000,
resolvePhoneNames: resolver,
},
);
expect(participants).toEqual([
{ id: "alice@example.com" },
{ id: "+15551234567", name: "Alice Existing" },
]);
expect(resolver).not.toHaveBeenCalled();
});
it("gracefully returns original participants when lookup fails", async () => {
const participants = [{ id: "+15551234567" }, { id: "+15557654321" }];
await expect(
enrichBlueBubblesParticipantsWithContactNames(participants, {
platform: "darwin",
now: () => 1_000,
resolvePhoneNames: vi.fn(async () => {
throw new Error("contacts unavailable");
}),
}),
).resolves.toBe(participants);
});
it("lists contacts databases from the current home directory", async () => {
const readdir = vi.fn(async () => ["source-a", "source-b"]);
const access = vi.fn(async (path: string) => {
if (!path.endsWith("source-a/AddressBook-v22.abcddb")) {
throw new Error("missing");
}
});
const databases = await listBlueBubblesContactsDatabasesForTest({
homeDir: "/Users/tester",
readdir,
access,
});
expect(readdir).toHaveBeenCalledWith(
"/Users/tester/Library/Application Support/AddressBook/Sources",
);
expect(databases).toEqual([
"/Users/tester/Library/Application Support/AddressBook/Sources/source-a/AddressBook-v22.abcddb",
]);
});
it("queries only the requested phone keys in sqlite", async () => {
const execFileAsync = vi.fn(async (_file: string, _args: string[], _options: unknown) => ({
stdout: "5551234567\tAlice Example\n5557654321\tBob Example\n",
stderr: "",
}));
const rows = await queryBlueBubblesContactsDatabaseForTest(
"/tmp/AddressBook-v22.abcddb",
["5551234567", "5557654321"],
{ execFileAsync },
);
expect(rows).toEqual([
{ phoneKey: "5551234567", name: "Alice Example" },
{ phoneKey: "5557654321", name: "Bob Example" },
]);
expect(execFileAsync).toHaveBeenCalledTimes(1);
const sql = execFileAsync.mock.calls[0]?.[1]?.[3];
expect(sql).toContain("WHERE digits IN ('5551234567', '5557654321')");
});
it("resolves names through the macOS contacts path across multiple databases", async () => {
const readdir = vi.fn(async () => ["source-a", "source-b"]);
const access = vi.fn(async () => undefined);
const execFileAsync = vi
.fn(async (_file: string, _args: string[], _options: unknown) => ({
stdout: "",
stderr: "",
}))
.mockResolvedValueOnce({ stdout: "5551234567\tAlice Example\n", stderr: "" })
.mockResolvedValueOnce({ stdout: "5557654321\tBob Example\n", stderr: "" });
const resolved = await resolveBlueBubblesParticipantContactNamesFromMacOsContactsForTest(
["5551234567", "5557654321"],
{
homeDir: "/Users/tester",
readdir,
access,
execFileAsync,
},
);
expect([...resolved.entries()]).toEqual([
["5551234567", "Alice Example"],
["5557654321", "Bob Example"],
]);
expect(execFileAsync).toHaveBeenCalledTimes(2);
});
it("skips contact lookup on non macOS hosts", async () => {
const participants = [{ id: "+15551234567" }];
const result = await enrichBlueBubblesParticipantsWithContactNames(participants, {
platform: "linux",
});
expect(result).toBe(participants);
});
});

View file

@ -0,0 +1,378 @@
import { execFile, type ExecFileOptionsWithStringEncoding } from "node:child_process";
import { access, readdir } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { BlueBubblesParticipant } from "./monitor-normalize.js";
const execFileAsync = promisify(execFile) as ExecFileRunner;
const CONTACT_NAME_CACHE_TTL_MS = 60 * 60 * 1000;
const NEGATIVE_CONTACT_NAME_CACHE_TTL_MS = 5 * 60 * 1000;
const MAX_PARTICIPANT_CONTACT_NAME_CACHE_ENTRIES = 2048;
const SQLITE_MAX_BUFFER = 8 * 1024 * 1024;
const SQLITE_PHONE_DIGITS_SQL =
"REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(p.ZFULLNUMBER, ''), ' ', ''), '(', ''), ')', ''), '-', ''), '+', ''), '.', ''), '\n', ''), '\r', '')";
type ContactNameCacheEntry = {
name?: string;
expiresAt: number;
};
type ResolvePhoneNamesFn = (phoneKeys: string[]) => Promise<Map<string, string>>;
type ExecFileRunner = (
file: string,
args: string[],
options: ExecFileOptionsWithStringEncoding,
) => Promise<{ stdout: string; stderr: string }>;
type ReadDirRunner = (path: string) => Promise<string[]>;
type AccessRunner = (path: string) => Promise<unknown>;
type ParticipantContactNameDeps = {
platform?: NodeJS.Platform;
now?: () => number;
resolvePhoneNames?: ResolvePhoneNamesFn;
homeDir?: string;
readdir?: ReadDirRunner;
access?: AccessRunner;
execFileAsync?: ExecFileRunner;
};
type ResolvedParticipantContactNameDeps = {
platform: NodeJS.Platform;
now: () => number;
resolvePhoneNames?: ResolvePhoneNamesFn;
homeDir?: string;
readdir: ReadDirRunner;
access: AccessRunner;
execFileAsync: ExecFileRunner;
};
const participantContactNameCache = new Map<string, ContactNameCacheEntry>();
let participantContactNameDepsForTest: ParticipantContactNameDeps | undefined;
function normalizePhoneLookupKey(value: string): string | null {
const digits = value.replace(/\D/g, "");
if (!digits) {
return null;
}
const normalized = digits.length === 11 && digits.startsWith("1") ? digits.slice(1) : digits;
return normalized.length >= 7 ? normalized : null;
}
function uniqueNormalizedPhoneLookupKeys(phoneKeys: string[]): string[] {
const unique = new Set<string>();
for (const phoneKey of phoneKeys) {
const normalized = normalizePhoneLookupKey(phoneKey);
if (normalized) {
unique.add(normalized);
}
}
return [...unique];
}
function resolveParticipantPhoneLookupKey(participant: BlueBubblesParticipant): string | null {
if (participant.id.includes("@")) {
return null;
}
return normalizePhoneLookupKey(participant.id);
}
function trimParticipantContactNameCache(now: number): void {
for (const [phoneKey, entry] of participantContactNameCache) {
if (entry.expiresAt <= now) {
participantContactNameCache.delete(phoneKey);
}
}
while (participantContactNameCache.size > MAX_PARTICIPANT_CONTACT_NAME_CACHE_ENTRIES) {
const oldestPhoneKey = participantContactNameCache.keys().next().value;
if (!oldestPhoneKey) {
return;
}
participantContactNameCache.delete(oldestPhoneKey);
}
}
function readFreshCacheEntry(phoneKey: string, now: number): ContactNameCacheEntry | null {
const cached = participantContactNameCache.get(phoneKey);
if (!cached) {
return null;
}
if (cached.expiresAt <= now) {
participantContactNameCache.delete(phoneKey);
return null;
}
participantContactNameCache.delete(phoneKey);
participantContactNameCache.set(phoneKey, cached);
return cached;
}
function writeCacheEntry(phoneKey: string, name: string | undefined, now: number): void {
participantContactNameCache.delete(phoneKey);
participantContactNameCache.set(phoneKey, {
name,
expiresAt: now + (name ? CONTACT_NAME_CACHE_TTL_MS : NEGATIVE_CONTACT_NAME_CACHE_TTL_MS),
});
trimParticipantContactNameCache(now);
}
function buildAddressBookSourcesDir(homeDir?: string): string | null {
const trimmedHomeDir = homeDir?.trim();
if (!trimmedHomeDir) {
return null;
}
return join(trimmedHomeDir, "Library", "Application Support", "AddressBook", "Sources");
}
async function fileExists(
path: string,
deps: ResolvedParticipantContactNameDeps,
): Promise<boolean> {
try {
await deps.access(path);
return true;
} catch {
return false;
}
}
async function listContactsDatabases(deps: ResolvedParticipantContactNameDeps): Promise<string[]> {
const sourcesDir = buildAddressBookSourcesDir(deps.homeDir);
if (!sourcesDir) {
return [];
}
let entries: string[] = [];
try {
entries = await deps.readdir(sourcesDir);
} catch {
return [];
}
const databases: string[] = [];
for (const entry of entries) {
const dbPath = join(sourcesDir, entry, "AddressBook-v22.abcddb");
if (await fileExists(dbPath, deps)) {
databases.push(dbPath);
}
}
return databases;
}
function buildSqlitePhoneKeyList(phoneKeys: string[]): string {
return uniqueNormalizedPhoneLookupKeys(phoneKeys)
.map((phoneKey) => `'${phoneKey}'`)
.join(", ");
}
async function queryContactsDatabase(
dbPath: string,
phoneKeys: string[],
deps: ResolvedParticipantContactNameDeps,
): Promise<Array<{ phoneKey: string; name: string }>> {
const sqlitePhoneKeyList = buildSqlitePhoneKeyList(phoneKeys);
if (!sqlitePhoneKeyList) {
return [];
}
const sql = `
SELECT digits, name
FROM (
SELECT
${SQLITE_PHONE_DIGITS_SQL} AS digits,
TRIM(
CASE
WHEN TRIM(COALESCE(r.ZFIRSTNAME, '') || ' ' || COALESCE(r.ZLASTNAME, '')) != ''
THEN TRIM(COALESCE(r.ZFIRSTNAME, '') || ' ' || COALESCE(r.ZLASTNAME, ''))
ELSE COALESCE(r.ZORGANIZATION, '')
END
) AS name
FROM ZABCDRECORD r
JOIN ZABCDPHONENUMBER p ON p.ZOWNER = r.Z_PK
WHERE p.ZFULLNUMBER IS NOT NULL
)
WHERE digits IN (${sqlitePhoneKeyList})
AND name != '';
`;
const options: ExecFileOptionsWithStringEncoding = {
encoding: "utf8",
maxBuffer: SQLITE_MAX_BUFFER,
};
const { stdout } = await deps.execFileAsync(
"sqlite3",
["-separator", "\t", dbPath, sql],
options,
);
const rows: Array<{ phoneKey: string; name: string }> = [];
for (const line of stdout.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const [digitsRaw, ...nameParts] = trimmed.split("\t");
const phoneKey = normalizePhoneLookupKey(digitsRaw ?? "");
const name = nameParts.join("\t").trim();
if (!phoneKey || !name) {
continue;
}
rows.push({ phoneKey, name });
}
return rows;
}
async function resolvePhoneNamesFromMacOsContacts(
phoneKeys: string[],
deps: ResolvedParticipantContactNameDeps,
): Promise<Map<string, string>> {
const normalizedPhoneKeys = uniqueNormalizedPhoneLookupKeys(phoneKeys);
if (normalizedPhoneKeys.length === 0) {
return new Map();
}
const databases = await listContactsDatabases(deps);
if (databases.length === 0) {
return new Map();
}
const unresolved = new Set(normalizedPhoneKeys);
const resolved = new Map<string, string>();
for (const dbPath of databases) {
let rows: Array<{ phoneKey: string; name: string }> = [];
try {
rows = await queryContactsDatabase(dbPath, [...unresolved], deps);
} catch {
continue;
}
for (const row of rows) {
if (!unresolved.has(row.phoneKey) || resolved.has(row.phoneKey)) {
continue;
}
resolved.set(row.phoneKey, row.name);
unresolved.delete(row.phoneKey);
if (unresolved.size === 0) {
return resolved;
}
}
}
return resolved;
}
function resolveLookupDeps(deps?: ParticipantContactNameDeps): ResolvedParticipantContactNameDeps {
const merged = {
...participantContactNameDepsForTest,
...deps,
};
return {
platform: merged.platform ?? process.platform,
now: merged.now ?? (() => Date.now()),
resolvePhoneNames: merged.resolvePhoneNames,
homeDir: merged.homeDir ?? process.env.HOME,
readdir: merged.readdir ?? readdir,
access: merged.access ?? access,
execFileAsync: merged.execFileAsync ?? execFileAsync,
};
}
export async function enrichBlueBubblesParticipantsWithContactNames(
participants: BlueBubblesParticipant[] | undefined,
deps?: ParticipantContactNameDeps,
): Promise<BlueBubblesParticipant[]> {
if (!Array.isArray(participants) || participants.length === 0) {
return [];
}
const resolvedDeps = resolveLookupDeps(deps);
const lookup =
resolvedDeps.resolvePhoneNames ??
((phoneKeys: string[]) => resolvePhoneNamesFromMacOsContacts(phoneKeys, resolvedDeps));
const shouldAttemptLookup =
Boolean(resolvedDeps.resolvePhoneNames) || resolvedDeps.platform === "darwin";
if (!shouldAttemptLookup) {
return participants;
}
const nowMs = resolvedDeps.now();
trimParticipantContactNameCache(nowMs);
const pendingPhoneKeys = new Set<string>();
const cachedNames = new Map<string, string>();
for (const participant of participants) {
if (participant.name?.trim()) {
continue;
}
const phoneKey = resolveParticipantPhoneLookupKey(participant);
if (!phoneKey) {
continue;
}
const cached = readFreshCacheEntry(phoneKey, nowMs);
if (cached?.name) {
cachedNames.set(phoneKey, cached.name);
continue;
}
if (!cached) {
pendingPhoneKeys.add(phoneKey);
}
}
if (pendingPhoneKeys.size > 0) {
try {
const resolved = await lookup([...pendingPhoneKeys]);
for (const phoneKey of pendingPhoneKeys) {
const name = normalizeOptionalString(resolved.get(phoneKey));
writeCacheEntry(phoneKey, name, nowMs);
if (name) {
cachedNames.set(phoneKey, name);
}
}
} catch {
return participants;
}
}
let didChange = false;
const enriched = participants.map((participant) => {
if (participant.name?.trim()) {
return participant;
}
const phoneKey = resolveParticipantPhoneLookupKey(participant);
if (!phoneKey) {
return participant;
}
const name = cachedNames.get(phoneKey)?.trim();
if (!name) {
return participant;
}
didChange = true;
return { ...participant, name };
});
return didChange ? enriched : participants;
}
export async function listBlueBubblesContactsDatabasesForTest(
deps?: ParticipantContactNameDeps,
): Promise<string[]> {
return listContactsDatabases(resolveLookupDeps(deps));
}
export async function queryBlueBubblesContactsDatabaseForTest(
dbPath: string,
phoneKeys: string[],
deps?: ParticipantContactNameDeps,
): Promise<Array<{ phoneKey: string; name: string }>> {
return queryContactsDatabase(dbPath, phoneKeys, resolveLookupDeps(deps));
}
export async function resolveBlueBubblesParticipantContactNamesFromMacOsContactsForTest(
phoneKeys: string[],
deps?: ParticipantContactNameDeps,
): Promise<Map<string, string>> {
return resolvePhoneNamesFromMacOsContacts(phoneKeys, resolveLookupDeps(deps));
}
export function resetBlueBubblesParticipantContactNameCacheForTest(): void {
participantContactNameCache.clear();
}
export function setBlueBubblesParticipantContactDepsForTest(
deps?: ParticipantContactNameDeps,
): void {
participantContactNameDepsForTest = deps;
participantContactNameCache.clear();
}

View file

@ -0,0 +1,174 @@
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { createBlueBubblesClientFromParts } from "./client.js";
import type { BaseProbeResult } from "./runtime-api.js";
import { normalizeSecretInputString } from "./secret-input.js";
export type BlueBubblesProbe = BaseProbeResult & {
status?: number | null;
};
export type BlueBubblesServerInfo = {
os_version?: string;
server_version?: string;
private_api?: boolean;
helper_connected?: boolean;
proxy_service?: string;
detected_icloud?: string;
computer_id?: string;
};
/** Cache server info by account ID to avoid repeated API calls.
* Size-capped to prevent unbounded growth (#4948). */
const MAX_SERVER_INFO_CACHE_SIZE = 64;
const serverInfoCache = new Map<string, { info: BlueBubblesServerInfo; expires: number }>();
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
/**
* Fetch server info from BlueBubbles API and cache it.
* Returns cached result if available and not expired.
*/
export async function fetchBlueBubblesServerInfo(params: {
baseUrl?: string | null;
password?: string | null;
accountId?: string;
timeoutMs?: number;
allowPrivateNetwork?: boolean;
}): Promise<BlueBubblesServerInfo | null> {
const baseUrl = normalizeSecretInputString(params.baseUrl);
const password = normalizeSecretInputString(params.password);
if (!baseUrl || !password) {
return null;
}
const cacheKey = normalizeOptionalString(params.accountId) || "default";
const cached = serverInfoCache.get(cacheKey);
if (cached && cached.expires > Date.now()) {
return cached.info;
}
const client = createBlueBubblesClientFromParts({
baseUrl,
password,
allowPrivateNetwork: params.allowPrivateNetwork === true,
timeoutMs: params.timeoutMs ?? 5000,
});
try {
const res = await client.getServerInfo({ timeoutMs: params.timeoutMs ?? 5000 });
if (!res.ok) {
return null;
}
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
const data = payload?.data as BlueBubblesServerInfo | undefined;
if (data) {
serverInfoCache.set(cacheKey, { info: data, expires: Date.now() + CACHE_TTL_MS });
// Evict oldest entries if cache exceeds max size
if (serverInfoCache.size > MAX_SERVER_INFO_CACHE_SIZE) {
const oldest = serverInfoCache.keys().next().value;
if (oldest !== undefined) {
serverInfoCache.delete(oldest);
}
}
}
return data ?? null;
} catch {
return null;
}
}
/**
* Get cached server info synchronously (for use in describeMessageTool).
* Returns null if not cached or expired.
*/
export function getCachedBlueBubblesServerInfo(accountId?: string): BlueBubblesServerInfo | null {
const cacheKey = normalizeOptionalString(accountId) || "default";
const cached = serverInfoCache.get(cacheKey);
if (cached && cached.expires > Date.now()) {
return cached.info;
}
return null;
}
/**
* Read cached private API capability for a BlueBubbles account.
* Returns null when capability is unknown (for example, before first probe).
*/
export function getCachedBlueBubblesPrivateApiStatus(accountId?: string): boolean | null {
const info = getCachedBlueBubblesServerInfo(accountId);
if (!info || typeof info.private_api !== "boolean") {
return null;
}
return info.private_api;
}
export function isBlueBubblesPrivateApiStatusEnabled(status: boolean | null): boolean {
return status === true;
}
export function isBlueBubblesPrivateApiEnabled(accountId?: string): boolean {
return isBlueBubblesPrivateApiStatusEnabled(getCachedBlueBubblesPrivateApiStatus(accountId));
}
/**
* Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number.
*/
export function parseMacOSMajorVersion(version?: string | null): number | null {
if (!version) {
return null;
}
const match = /^(\d+)/.exec(version.trim());
return match ? Number.parseInt(match[1], 10) : null;
}
/**
* Check if the cached server info indicates macOS 26 or higher.
* Returns false if no cached info is available (fail open for action listing).
*/
export function isMacOS26OrHigher(accountId?: string): boolean {
const info = getCachedBlueBubblesServerInfo(accountId);
if (!info?.os_version) {
return false;
}
const major = parseMacOSMajorVersion(info.os_version);
return major !== null && major >= 26;
}
/** Clear the server info cache (for testing) */
export function clearServerInfoCache(): void {
serverInfoCache.clear();
}
export async function probeBlueBubbles(params: {
baseUrl?: string | null;
password?: string | null;
timeoutMs?: number;
allowPrivateNetwork?: boolean;
}): Promise<BlueBubblesProbe> {
const baseUrl = normalizeSecretInputString(params.baseUrl);
const password = normalizeSecretInputString(params.password);
if (!baseUrl) {
return { ok: false, error: "serverUrl not configured" };
}
if (!password) {
return { ok: false, error: "password not configured" };
}
const client = createBlueBubblesClientFromParts({
baseUrl,
password,
allowPrivateNetwork: params.allowPrivateNetwork === true,
timeoutMs: params.timeoutMs,
});
try {
const res = await client.ping({ timeoutMs: params.timeoutMs });
if (!res.ok) {
return { ok: false, status: res.status, error: `HTTP ${res.status}` };
}
return { ok: true, status: res.status };
} catch (err) {
return {
ok: false,
status: null,
error: formatErrorMessage(err),
};
}
}

View file

@ -0,0 +1,417 @@
import { describe, expect, it, vi } from "vitest";
import {
normalizeBlueBubblesReactionInput,
normalizeBlueBubblesReactionInputStrict,
sendBlueBubblesReaction,
} from "./reactions.js";
import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
vi.mock("./accounts.js", async () => {
const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js");
return createBlueBubblesAccountsMockModule();
});
const mockFetch = vi.fn();
const noopPrivateApiStatusMock = {
mockReturnValue: () => {},
};
installBlueBubblesFetchTestHooks({
mockFetch,
privateApiStatusMock: noopPrivateApiStatusMock,
});
describe("reactions", () => {
describe("sendBlueBubblesReaction", () => {
async function expectRemovedReaction(emoji: string, expectedReaction = "-love") {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji,
remove: true,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe(expectedReaction);
}
it("throws when chatGuid is empty", async () => {
await expect(
sendBlueBubblesReaction({
chatGuid: "",
messageGuid: "msg-123",
emoji: "love",
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
}),
).rejects.toThrow("chatGuid");
});
it("throws when messageGuid is empty", async () => {
await expect(
sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "",
emoji: "love",
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
}),
).rejects.toThrow("messageGuid");
});
it("throws when emoji is empty", async () => {
await expect(
sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "",
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
}),
).rejects.toThrow("emoji or name");
});
it("throws when serverUrl is missing", async () => {
await expect(
sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "love",
opts: {},
}),
).rejects.toThrow("serverUrl is required");
});
it("throws when password is missing", async () => {
await expect(
sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "love",
opts: {
serverUrl: "http://localhost:1234",
},
}),
).rejects.toThrow("password is required");
});
it("falls back to love for unsupported reaction type", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "👀",
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe("love");
});
describe("reaction type normalization", () => {
const testCases = [
{ input: "love", expected: "love" },
{ input: "like", expected: "like" },
{ input: "dislike", expected: "dislike" },
{ input: "laugh", expected: "laugh" },
{ input: "emphasize", expected: "emphasize" },
{ input: "question", expected: "question" },
{ input: "heart", expected: "love" },
{ input: "thumbs_up", expected: "like" },
{ input: "thumbs-down", expected: "dislike" },
{ input: "thumbs_down", expected: "dislike" },
{ input: "haha", expected: "laugh" },
{ input: "lol", expected: "laugh" },
{ input: "emphasis", expected: "emphasize" },
{ input: "exclaim", expected: "emphasize" },
{ input: "❤️", expected: "love" },
{ input: "❤", expected: "love" },
{ input: "♥️", expected: "love" },
{ input: "😍", expected: "love" },
{ input: "👍", expected: "like" },
{ input: "👎", expected: "dislike" },
{ input: "😂", expected: "laugh" },
{ input: "🤣", expected: "laugh" },
{ input: "😆", expected: "laugh" },
{ input: "‼️", expected: "emphasize" },
{ input: "‼", expected: "emphasize" },
{ input: "❗", expected: "emphasize" },
{ input: "❓", expected: "question" },
{ input: "❔", expected: "question" },
{ input: "LOVE", expected: "love" },
{ input: "Like", expected: "like" },
];
for (const { input, expected } of testCases) {
it(`normalizes "${input}" to "${expected}"`, async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: input,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe(expected);
});
}
});
it("sends reaction successfully", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "iMessage;-;+15551234567",
messageGuid: "msg-uuid-123",
emoji: "love",
opts: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/message/react"),
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.chatGuid).toBe("iMessage;-;+15551234567");
expect(body.selectedMessageGuid).toBe("msg-uuid-123");
expect(body.reaction).toBe("love");
expect(body.partIndex).toBe(0);
});
it("includes password in URL query", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "like",
opts: {
serverUrl: "http://localhost:1234",
password: "my-react-password",
},
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("password=my-react-password");
});
it("sends reaction removal with dash prefix", async () => {
await expectRemovedReaction("love");
});
it("strips leading dash from emoji when remove flag is set", async () => {
await expectRemovedReaction("-love");
});
it("falls back to removing love for unsupported removal reactions", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "👀",
remove: true,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe("-love");
});
it("uses custom partIndex when provided", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "laugh",
partIndex: 3,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.partIndex).toBe(3);
});
it("throws on non-ok response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 400,
text: () => Promise.resolve("Invalid reaction type"),
});
await expect(
sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "like",
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
}),
).rejects.toThrow("reaction failed (400): Invalid reaction type");
});
it("resolves credentials from config", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "emphasize",
opts: {
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://react-server:7777",
password: "react-pass",
},
},
},
},
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("react-server:7777");
expect(calledUrl).toContain("password=react-pass");
});
it("trims chatGuid and messageGuid", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: " chat-with-spaces ",
messageGuid: " msg-with-spaces ",
emoji: "question",
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.chatGuid).toBe("chat-with-spaces");
expect(body.selectedMessageGuid).toBe("msg-with-spaces");
});
describe("reaction removal aliases", () => {
it("handles emoji-based removal", async () => {
await expectRemovedReaction("👍", "-like");
});
it("handles text alias removal", async () => {
await expectRemovedReaction("haha", "-laugh");
});
});
});
describe("normalizeBlueBubblesReactionInputStrict", () => {
it("maps supported emoji to canonical type", () => {
expect(normalizeBlueBubblesReactionInputStrict("👍")).toBe("like");
expect(normalizeBlueBubblesReactionInputStrict("❤️")).toBe("love");
expect(normalizeBlueBubblesReactionInputStrict("😂")).toBe("laugh");
});
it("throws on unsupported input so validators can detect misconfiguration", () => {
expect(() => normalizeBlueBubblesReactionInputStrict("👀")).toThrow(
/Unsupported BlueBubbles reaction/,
);
expect(() => normalizeBlueBubblesReactionInputStrict("🎉")).toThrow(
/Unsupported BlueBubbles reaction/,
);
});
it("throws on empty input", () => {
expect(() => normalizeBlueBubblesReactionInputStrict("")).toThrow(
/requires an emoji or name/,
);
expect(() => normalizeBlueBubblesReactionInputStrict(" ")).toThrow(
/requires an emoji or name/,
);
});
});
describe("normalizeBlueBubblesReactionInput (lenient)", () => {
it("maps supported emoji to canonical type", () => {
expect(normalizeBlueBubblesReactionInput("👍")).toBe("like");
expect(normalizeBlueBubblesReactionInput("❤️")).toBe("love");
});
it("falls back to love when input is unsupported by iMessage tapback", () => {
expect(normalizeBlueBubblesReactionInput("👀")).toBe("love");
expect(normalizeBlueBubblesReactionInput("🎉")).toBe("love");
});
it("falls back to -love on unsupported remove", () => {
expect(normalizeBlueBubblesReactionInput("👀", true)).toBe("-love");
});
it("still throws on empty input (strict error bubbles up unchanged)", () => {
// Empty input is a contract error from the caller, not a decorative
// emoji the model picked; we intentionally do not mask it.
expect(() => normalizeBlueBubblesReactionInput("")).toThrow(/requires an emoji or name/);
});
});
});

View file

@ -0,0 +1,203 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { createBlueBubblesClient } from "./client.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import type { OpenClawConfig } from "./runtime-api.js";
export type BlueBubblesReactionOpts = {
serverUrl?: string;
password?: string;
accountId?: string;
timeoutMs?: number;
cfg?: OpenClawConfig;
};
const REACTION_TYPES = new Set(["love", "like", "dislike", "laugh", "emphasize", "question"]);
const REACTION_ALIASES = new Map<string, string>([
// General
["heart", "love"],
["love", "love"],
["❤", "love"],
["❤️", "love"],
["red_heart", "love"],
["thumbs_up", "like"],
["thumbsup", "like"],
["thumbs-up", "like"],
["thumbsup", "like"],
["like", "like"],
["thumb", "like"],
["ok", "like"],
["thumbs_down", "dislike"],
["thumbsdown", "dislike"],
["thumbs-down", "dislike"],
["dislike", "dislike"],
["boo", "dislike"],
["no", "dislike"],
// Laugh
["haha", "laugh"],
["lol", "laugh"],
["lmao", "laugh"],
["rofl", "laugh"],
["😂", "laugh"],
["🤣", "laugh"],
["xd", "laugh"],
["laugh", "laugh"],
// Emphasize / exclaim
["emphasis", "emphasize"],
["emphasize", "emphasize"],
["exclaim", "emphasize"],
["!!", "emphasize"],
["‼", "emphasize"],
["‼️", "emphasize"],
["❗", "emphasize"],
["important", "emphasize"],
["bang", "emphasize"],
// Question
["question", "question"],
["?", "question"],
["❓", "question"],
["❔", "question"],
["ask", "question"],
// Apple/Messages names
["loved", "love"],
["liked", "like"],
["disliked", "dislike"],
["laughed", "laugh"],
["emphasized", "emphasize"],
["questioned", "question"],
// Colloquial / informal
["fire", "love"],
["🔥", "love"],
["wow", "emphasize"],
["!", "emphasize"],
// Edge: generic emoji name forms
["heart_eyes", "love"],
["smile", "laugh"],
["smiley", "laugh"],
["happy", "laugh"],
["joy", "laugh"],
]);
const REACTION_EMOJIS = new Map<string, string>([
// Love
["❤️", "love"],
["❤", "love"],
["♥️", "love"],
["♥", "love"],
["😍", "love"],
["💕", "love"],
// Like
["👍", "like"],
["👌", "like"],
// Dislike
["👎", "dislike"],
["🙅", "dislike"],
// Laugh
["😂", "laugh"],
["🤣", "laugh"],
["😆", "laugh"],
["😁", "laugh"],
["😹", "laugh"],
// Emphasize
["‼️", "emphasize"],
["‼", "emphasize"],
["!!", "emphasize"],
["❗", "emphasize"],
["❕", "emphasize"],
["!", "emphasize"],
// Question
["❓", "question"],
["❔", "question"],
["?", "question"],
]);
const UNSUPPORTED_REACTION_ERROR = "UnsupportedBlueBubblesReaction";
/**
* Strict normalizer: throws when the input does not map to a supported
* BlueBubbles reaction type. Use this for validator-style callers that
* need to detect unsupported input (e.g. config sanity checks) rather
* than gracefully substituting a fallback.
*/
export function normalizeBlueBubblesReactionInputStrict(emoji: string, remove?: boolean): string {
const trimmed = emoji.trim();
if (!trimmed) {
throw new Error("BlueBubbles reaction requires an emoji or name.");
}
let raw = normalizeLowercaseStringOrEmpty(trimmed);
if (raw.startsWith("-")) {
raw = raw.slice(1);
}
const aliased = REACTION_ALIASES.get(raw) ?? raw;
const mapped = REACTION_EMOJIS.get(trimmed) ?? REACTION_EMOJIS.get(raw) ?? aliased;
if (!REACTION_TYPES.has(mapped)) {
const error = new Error(`Unsupported BlueBubbles reaction: ${trimmed}`);
error.name = UNSUPPORTED_REACTION_ERROR;
throw error;
}
return remove ? `-${mapped}` : mapped;
}
/**
* Lenient normalizer: when the input does not map to a supported
* BlueBubbles reaction type (iMessage tapback only supports
* love/like/dislike/laugh/emphasize/question), fall back to `love`
* so agents that react with a wider emoji vocabulary (e.g. 👀 to
* ack "seen, working on it") still produce a visible tapback instead
* of failing the whole reaction request.
*
* Contract errors (empty input) continue to bubble up so callers
* still catch misuse.
*
* Use this for model-facing paths. Callers that need to detect
* unsupported input should use {@link normalizeBlueBubblesReactionInputStrict}.
*/
export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {
try {
return normalizeBlueBubblesReactionInputStrict(emoji, remove);
} catch (error) {
if (error instanceof Error && error.name === UNSUPPORTED_REACTION_ERROR) {
return remove ? "-love" : "love";
}
throw error;
}
}
export async function sendBlueBubblesReaction(params: {
chatGuid: string;
messageGuid: string;
emoji: string;
remove?: boolean;
partIndex?: number;
opts?: BlueBubblesReactionOpts;
}): Promise<void> {
const chatGuid = params.chatGuid.trim();
const messageGuid = params.messageGuid.trim();
if (!chatGuid) {
throw new Error("BlueBubbles reaction requires chatGuid.");
}
if (!messageGuid) {
throw new Error("BlueBubbles reaction requires messageGuid.");
}
const reaction = normalizeBlueBubblesReactionInput(params.emoji, params.remove);
const client = createBlueBubblesClient(params.opts ?? {});
if (getCachedBlueBubblesPrivateApiStatus(client.accountId) === false) {
throw new Error(
"BlueBubbles reaction requires Private API, but it is disabled on the BlueBubbles server.",
);
}
// Go through the client's typed `react` method — it uses the same SSRF policy
// as every other client call, eliminating the asymmetric `{}` vs
// `{ allowedHostnames }` path that caused #59722.
const res = await client.react({
chatGuid,
selectedMessageGuid: messageGuid,
reaction,
partIndex: typeof params.partIndex === "number" ? params.partIndex : 0,
timeoutMs: params.opts?.timeoutMs,
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`BlueBubbles reaction failed (${res.status}): ${errorText || "unknown"}`);
}
}

View file

@ -0,0 +1 @@
export { resolveRequestUrl } from "openclaw/plugin-sdk/request-url";

View file

@ -0,0 +1,61 @@
export { resolveAckReaction } from "openclaw/plugin-sdk/agent-runtime";
export {
createActionGate,
jsonResult,
readNumberParam,
readReactionParams,
readStringParam,
} from "openclaw/plugin-sdk/channel-actions";
export type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
export {
evictOldHistoryKeys,
recordPendingHistoryEntryIfEnabled,
} from "openclaw/plugin-sdk/reply-history";
export { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
export { logAckFailure, logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
export { BLUEBUBBLES_ACTION_NAMES, BLUEBUBBLES_ACTIONS } from "./actions-contract.js";
export { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";
export { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
export { collectBlueBubblesStatusIssues } from "./status-issues.js";
export type {
BaseProbeResult,
ChannelAccountSnapshot,
ChannelMessageActionAdapter,
ChannelMessageActionName,
} from "openclaw/plugin-sdk/channel-contract";
export type {
ChannelPlugin,
OpenClawConfig,
PluginRuntime,
} from "openclaw/plugin-sdk/channel-core";
export { parseFiniteNumber } from "openclaw/plugin-sdk/infra-runtime";
export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
export {
DM_GROUP_ACCESS_REASON,
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
} from "openclaw/plugin-sdk/channel-policy";
export { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
export { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
export { resolveRequestUrl } from "openclaw/plugin-sdk/request-url";
export { buildProbeChannelStatusSummary } from "openclaw/plugin-sdk/channel-status";
export { stripMarkdown } from "openclaw/plugin-sdk/text-runtime";
export { extractToolSend } from "openclaw/plugin-sdk/tool-send";
export {
WEBHOOK_RATE_LIMIT_DEFAULTS,
createFixedWindowRateLimiter,
createWebhookInFlightLimiter,
readWebhookBodyOrReject,
registerWebhookTargetWithPluginRoute,
resolveRequestClientIp,
resolveWebhookTargetWithAuthOrRejectSync,
withResolvedWebhookRequestPipeline,
} from "openclaw/plugin-sdk/webhook-ingress";
export { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/config-runtime";
export {
evaluateSupplementalContextVisibility,
shouldIncludeSupplementalContext,
} from "openclaw/plugin-sdk/security-runtime";

View file

@ -0,0 +1,32 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import type { PluginRuntime } from "./runtime-api.js";
const runtimeStore = createPluginRuntimeStore<PluginRuntime>({
pluginId: "bluebubbles",
errorMessage: "BlueBubbles runtime not initialized",
});
type LegacyRuntimeLogShape = { log?: (message: string) => void };
export const setBlueBubblesRuntime = runtimeStore.setRuntime;
export function clearBlueBubblesRuntime(): void {
runtimeStore.clearRuntime();
}
export function tryGetBlueBubblesRuntime(): PluginRuntime | null {
return runtimeStore.tryGetRuntime();
}
export function getBlueBubblesRuntime(): PluginRuntime {
return runtimeStore.getRuntime();
}
export function warnBlueBubbles(message: string): void {
const formatted = `[bluebubbles] ${message}`;
// Backward-compatible with tests/legacy injections that pass { log }.
const log = (runtimeStore.tryGetRuntime() as unknown as LegacyRuntimeLogShape | null)?.log;
if (typeof log === "function") {
log(formatted);
return;
}
console.warn(formatted);
}

View file

@ -0,0 +1,59 @@
import {
collectSimpleChannelFieldAssignments,
getChannelSurface,
type ResolverContext,
type SecretDefaults,
type SecretTargetRegistryEntry,
} from "openclaw/plugin-sdk/channel-secret-basic-runtime";
export const secretTargetRegistryEntries = [
{
id: "channels.bluebubbles.accounts.*.password",
targetType: "channels.bluebubbles.accounts.*.password",
configFile: "openclaw.json",
pathPattern: "channels.bluebubbles.accounts.*.password",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.bluebubbles.password",
targetType: "channels.bluebubbles.password",
configFile: "openclaw.json",
pathPattern: "channels.bluebubbles.password",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
] satisfies SecretTargetRegistryEntry[];
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults?: SecretDefaults;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "bluebubbles");
if (!resolved) {
return;
}
const { channel: bluebubbles, surface } = resolved;
collectSimpleChannelFieldAssignments({
channelKey: "bluebubbles",
field: "password",
channel: bluebubbles,
surface,
defaults: params.defaults,
context: params.context,
topInactiveReason: "no enabled account inherits this top-level BlueBubbles password.",
accountInactiveReason: "BlueBubbles account is disabled.",
});
}
export const channelSecrets = {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
};

View file

@ -0,0 +1,6 @@
export {
buildSecretInputSchema,
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/secret-input";

View file

@ -0,0 +1,61 @@
import { asRecord } from "./monitor-normalize.js";
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
import type { BlueBubblesSendTarget } from "./types.js";
export function resolveBlueBubblesSendTarget(raw: string): BlueBubblesSendTarget {
const parsed = parseBlueBubblesTarget(raw);
if (parsed.kind === "handle") {
return {
kind: "handle",
address: normalizeBlueBubblesHandle(parsed.to),
service: parsed.service,
};
}
if (parsed.kind === "chat_id") {
return { kind: "chat_id", chatId: parsed.chatId };
}
if (parsed.kind === "chat_guid") {
return { kind: "chat_guid", chatGuid: parsed.chatGuid };
}
return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
}
export function extractBlueBubblesMessageId(payload: unknown): string {
if (!payload || typeof payload !== "object") {
return "unknown";
}
const record = payload as Record<string, unknown>;
const dataRecord = asRecord(record.data);
const resultRecord = asRecord(record.result);
const payloadRecord = asRecord(record.payload);
const messageRecord = asRecord(record.message);
const dataArrayFirst = Array.isArray(record.data) ? asRecord(record.data[0]) : null;
const roots = [record, dataRecord, resultRecord, payloadRecord, messageRecord, dataArrayFirst];
for (const root of roots) {
if (!root) {
continue;
}
const candidates = [
root.message_id,
root.messageId,
root.messageGuid,
root.message_guid,
root.guid,
root.id,
root.uuid,
];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim()) {
return candidate.trim();
}
if (typeof candidate === "number" && Number.isFinite(candidate)) {
return String(candidate);
}
}
}
return "unknown";
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,612 @@
import crypto from "node:crypto";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
normalizeOptionalString,
stripMarkdown,
} from "openclaw/plugin-sdk/text-runtime";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { createBlueBubblesClient, createBlueBubblesClientFromParts } from "./client.js";
import {
fetchBlueBubblesServerInfo,
getCachedBlueBubblesPrivateApiStatus,
isBlueBubblesPrivateApiStatusEnabled,
isMacOS26OrHigher,
} from "./probe.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { warnBlueBubbles } from "./runtime.js";
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
import { type BlueBubblesSendTarget } from "./types.js";
export type BlueBubblesSendOpts = {
serverUrl?: string;
password?: string;
accountId?: string;
timeoutMs?: number;
cfg?: OpenClawConfig;
/** Message GUID to reply to (reply threading) */
replyToMessageGuid?: string;
/** Part index for reply (default: 0) */
replyToPartIndex?: number;
/** Effect ID or short name for message effects (e.g., "slam", "balloons") */
effectId?: string;
};
export type BlueBubblesSendResult = {
messageId: string;
};
/** Maps short effect names to full Apple effect IDs */
const EFFECT_MAP: Record<string, string> = {
// Bubble effects
slam: "com.apple.MobileSMS.expressivesend.impact",
loud: "com.apple.MobileSMS.expressivesend.loud",
gentle: "com.apple.MobileSMS.expressivesend.gentle",
invisible: "com.apple.MobileSMS.expressivesend.invisibleink",
"invisible-ink": "com.apple.MobileSMS.expressivesend.invisibleink",
"invisible ink": "com.apple.MobileSMS.expressivesend.invisibleink",
invisibleink: "com.apple.MobileSMS.expressivesend.invisibleink",
// Screen effects
echo: "com.apple.messages.effect.CKEchoEffect",
spotlight: "com.apple.messages.effect.CKSpotlightEffect",
balloons: "com.apple.messages.effect.CKHappyBirthdayEffect",
confetti: "com.apple.messages.effect.CKConfettiEffect",
love: "com.apple.messages.effect.CKHeartEffect",
heart: "com.apple.messages.effect.CKHeartEffect",
hearts: "com.apple.messages.effect.CKHeartEffect",
lasers: "com.apple.messages.effect.CKLasersEffect",
fireworks: "com.apple.messages.effect.CKFireworksEffect",
celebration: "com.apple.messages.effect.CKSparklesEffect",
};
function resolveEffectId(raw?: string): string | undefined {
const trimmed = normalizeOptionalLowercaseString(raw);
if (!trimmed) {
return undefined;
}
if (EFFECT_MAP[trimmed]) {
return EFFECT_MAP[trimmed];
}
const normalized = trimmed.replace(/[\s_]+/g, "-");
if (EFFECT_MAP[normalized]) {
return EFFECT_MAP[normalized];
}
const compact = trimmed.replace(/[\s_-]+/g, "");
if (EFFECT_MAP[compact]) {
return EFFECT_MAP[compact];
}
return raw;
}
type PrivateApiDecision = {
canUsePrivateApi: boolean;
throwEffectDisabledError: boolean;
warningMessage?: string;
};
function resolvePrivateApiDecision(params: {
privateApiStatus: boolean | null;
wantsReplyThread: boolean;
wantsEffect: boolean;
accountId?: string;
}): PrivateApiDecision {
const { privateApiStatus, wantsReplyThread, wantsEffect, accountId } = params;
const needsPrivateApi = wantsReplyThread || wantsEffect;
// On macOS 26 Tahoe, AppleScript Messages.app automation is broken
// (`-1700` error) for outbound sends. Prefer Private API even for plain
// text when it is available so sends still reach the recipient.
// (#53159 Bug B, #64480)
const forceOnMacOS26 =
isMacOS26OrHigher(accountId) && isBlueBubblesPrivateApiStatusEnabled(privateApiStatus);
const canUsePrivateApi =
(needsPrivateApi || forceOnMacOS26) && isBlueBubblesPrivateApiStatusEnabled(privateApiStatus);
const throwEffectDisabledError = wantsEffect && privateApiStatus === false;
if (!needsPrivateApi || privateApiStatus !== null) {
return { canUsePrivateApi, throwEffectDisabledError };
}
const requested = [
wantsReplyThread ? "reply threading" : null,
wantsEffect ? "message effects" : null,
]
.filter(Boolean)
.join(" + ");
return {
canUsePrivateApi,
throwEffectDisabledError,
warningMessage: `Private API status unknown; sending without ${requested}. Run a status probe to restore private-api features.`,
};
}
async function parseBlueBubblesMessageResponse(res: Response): Promise<BlueBubblesSendResult> {
const body = await res.text();
if (!body) {
return { messageId: "ok" };
}
try {
const parsed = JSON.parse(body) as unknown;
return { messageId: extractBlueBubblesMessageId(parsed) };
} catch {
return { messageId: "ok" };
}
}
type BlueBubblesChatRecord = Record<string, unknown>;
function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
const candidates = [
chat.chatGuid,
chat.guid,
chat.chat_guid,
chat.identifier,
chat.chatIdentifier,
chat.chat_identifier,
];
for (const candidate of candidates) {
const value = normalizeOptionalString(candidate);
if (value) {
return value;
}
}
return null;
}
function extractChatId(chat: BlueBubblesChatRecord): number | null {
const candidates = [chat.chatId, chat.id, chat.chat_id];
for (const candidate of candidates) {
if (typeof candidate === "number" && Number.isFinite(candidate)) {
return candidate;
}
}
return null;
}
function extractChatIdentifierFromChatGuid(chatGuid: string): string | null {
const parts = chatGuid.split(";");
if (parts.length < 3) {
return null;
}
return normalizeOptionalString(parts[2]) ?? null;
}
function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] {
const raw =
(Array.isArray(chat.participants) ? chat.participants : null) ??
(Array.isArray(chat.handles) ? chat.handles : null) ??
(Array.isArray(chat.participantHandles) ? chat.participantHandles : null);
if (!raw) {
return [];
}
const out: string[] = [];
for (const entry of raw) {
if (typeof entry === "string") {
out.push(entry);
continue;
}
if (entry && typeof entry === "object") {
const record = entry as Record<string, unknown>;
const candidate =
(typeof record.address === "string" && record.address) ||
(typeof record.handle === "string" && record.handle) ||
(typeof record.id === "string" && record.id) ||
(typeof record.identifier === "string" && record.identifier);
if (candidate) {
out.push(candidate);
}
}
}
return out;
}
async function queryChats(params: {
baseUrl: string;
password: string;
timeoutMs?: number;
offset: number;
limit: number;
allowPrivateNetwork?: boolean;
}): Promise<BlueBubblesChatRecord[]> {
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<string, unknown> | 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;
allowPrivateNetwork?: boolean;
}): Promise<string | null> {
if (params.target.kind === "chat_guid") {
return params.target.chatGuid;
}
const normalizedHandle =
params.target.kind === "handle" ? normalizeBlueBubblesHandle(params.target.address) : "";
const targetChatId = params.target.kind === "chat_id" ? params.target.chatId : null;
const targetChatIdentifier =
params.target.kind === "chat_identifier" ? params.target.chatIdentifier : null;
const limit = 500;
// When matching by handle, prefer the caller's requested service. A user may
// have both an `iMessage;-;<handle>` and `SMS;-;<handle>` chat:
// - default / `service: "imessage"` / `service: "auto"` -> prefer iMessage
// so we never silently downgrade to SMS when iMessage is available.
// - explicit `service: "sms"` (e.g. caller passed `sms:+15551234567`) ->
// prefer SMS so explicit SMS intent is respected.
//
// A direct `<preferred>;-;<handle>` match is the strongest signal and
// returns immediately. Everything else is recorded as a ranked fallback.
const preferredService: "iMessage" | "SMS" =
params.target.kind === "handle" && params.target.service === "sms" ? "SMS" : "iMessage";
const preferredPrefix = `${preferredService};-;`;
const otherPrefix = preferredService === "iMessage" ? "SMS;-;" : "iMessage;-;";
// Note: a direct `preferredPrefix` match `return`s immediately below, so we
// only need to remember the other-service and unknown-service direct fallbacks.
let directHandleOtherServiceMatch: string | null = null;
let directHandleUnknownServiceMatch: string | null = null;
let participantPreferredMatch: string | null = null;
let participantOtherServiceMatch: string | null = null;
let participantUnknownServiceMatch: string | null = null;
for (let offset = 0; offset < 5000; offset += limit) {
const chats = await queryChats({
baseUrl: params.baseUrl,
password: params.password,
timeoutMs: params.timeoutMs,
offset,
limit,
allowPrivateNetwork: params.allowPrivateNetwork,
});
if (chats.length === 0) {
break;
}
for (const chat of chats) {
if (targetChatId != null) {
const chatId = extractChatId(chat);
if (chatId != null && chatId === targetChatId) {
return extractChatGuid(chat);
}
}
if (targetChatIdentifier) {
const guid = extractChatGuid(chat);
if (guid) {
// Back-compat: some callers might pass a full chat GUID.
if (guid === targetChatIdentifier) {
return guid;
}
// Primary match: BlueBubbles `chat_identifier:*` targets correspond to the
// third component of the chat GUID: `service;(+|-) ;identifier`.
const guidIdentifier = extractChatIdentifierFromChatGuid(guid);
if (guidIdentifier && guidIdentifier === targetChatIdentifier) {
return guid;
}
}
const identifier =
typeof chat.identifier === "string"
? chat.identifier
: typeof chat.chatIdentifier === "string"
? chat.chatIdentifier
: typeof chat.chat_identifier === "string"
? chat.chat_identifier
: "";
if (identifier && identifier === targetChatIdentifier) {
return guid ?? extractChatGuid(chat);
}
}
if (normalizedHandle) {
const guid = extractChatGuid(chat);
const directHandle = guid ? extractHandleFromChatGuid(guid) : null;
if (directHandle && directHandle === normalizedHandle && guid) {
// A direct `<preferredPrefix><handle>` is the strongest signal and we
// can return immediately. Other services are remembered as fallbacks
// and we keep scanning in case a preferred-service chat exists later.
if (guid.startsWith(preferredPrefix)) {
return guid;
}
if (guid.startsWith(otherPrefix)) {
if (!directHandleOtherServiceMatch) {
directHandleOtherServiceMatch = guid;
}
} else if (!directHandleUnknownServiceMatch) {
// Unknown service; treat as a last-resort direct match.
directHandleUnknownServiceMatch = guid;
}
}
if (guid) {
// Only consider DM chats (`;-;` separator) as participant matches.
// Group chats (`;+;` separator) should never match when searching by handle/phone.
// This prevents routing "send to +1234567890" to a group chat that contains that number.
const isDmChat = guid.includes(";-;");
if (isDmChat) {
const participants = extractParticipantAddresses(chat).map((entry) =>
normalizeBlueBubblesHandle(entry),
);
if (participants.includes(normalizedHandle)) {
if (guid.startsWith(preferredPrefix)) {
if (!participantPreferredMatch) {
participantPreferredMatch = guid;
}
} else if (guid.startsWith(otherPrefix)) {
if (!participantOtherServiceMatch) {
participantOtherServiceMatch = guid;
}
} else if (!participantUnknownServiceMatch) {
participantUnknownServiceMatch = guid;
}
}
}
}
}
}
// We deliberately do NOT break early on participant or non-preferred direct
// matches: a higher-priority direct `<preferredPrefix><handle>` chat may
// still exist on a later page, and only that branch can short-circuit.
}
return (
participantPreferredMatch ??
directHandleOtherServiceMatch ??
participantOtherServiceMatch ??
directHandleUnknownServiceMatch ??
participantUnknownServiceMatch
);
}
/**
* Creates a new DM chat for the given address and returns the chat GUID.
* Requires Private API to be enabled in BlueBubbles.
*
* If a `message` is provided it is sent as the initial message in the new chat;
* otherwise an empty-string message body is used (BlueBubbles still creates the
* chat but will not deliver a visible bubble).
*/
export async function createChatForHandle(params: {
baseUrl: string;
password: string;
address: string;
message?: string;
timeoutMs?: number;
allowPrivateNetwork?: boolean;
}): Promise<{ chatGuid: string | null; messageId: string }> {
const client = createBlueBubblesClientFromParts({
baseUrl: params.baseUrl,
password: params.password,
allowPrivateNetwork: params.allowPrivateNetwork === true,
timeoutMs: params.timeoutMs,
});
const payload = {
addresses: [params.address],
message: params.message ?? "",
tempGuid: `temp-${crypto.randomUUID()}`,
};
const res = await client.request({
method: "POST",
path: "/api/v1/chat/new",
body: payload,
timeoutMs: params.timeoutMs,
});
if (!res.ok) {
const errorText = await res.text();
if (
res.status === 400 ||
res.status === 403 ||
normalizeLowercaseStringOrEmpty(errorText).includes("private api")
) {
throw new Error(
`BlueBubbles send failed: Cannot create new chat - Private API must be enabled. Original error: ${errorText || res.status}`,
);
}
throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`);
}
const body = await res.text();
let messageId = "ok";
let chatGuid: string | null = null;
if (body) {
try {
const parsed = JSON.parse(body) as Record<string, unknown>;
messageId = extractBlueBubblesMessageId(parsed);
// Extract chatGuid from the response data
const data = parsed.data as Record<string, unknown> | undefined;
if (data) {
chatGuid =
(typeof data.chatGuid === "string" && data.chatGuid) ||
(typeof data.guid === "string" && data.guid) ||
null;
// Also try nested chats array (some BB versions nest it)
if (!chatGuid) {
const chats = data.chats ?? data.chat;
if (Array.isArray(chats) && chats.length > 0) {
const first = chats[0] as Record<string, unknown> | undefined;
chatGuid =
(typeof first?.guid === "string" && first.guid) ||
(typeof first?.chatGuid === "string" && first.chatGuid) ||
null;
} else if (chats && typeof chats === "object" && !Array.isArray(chats)) {
const chatObj = chats as Record<string, unknown>;
chatGuid =
(typeof chatObj.guid === "string" && chatObj.guid) ||
(typeof chatObj.chatGuid === "string" && chatObj.chatGuid) ||
null;
}
}
}
} catch {
// ignore parse errors
}
}
return { chatGuid, messageId };
}
/**
* Creates a new chat (DM) and sends an initial message.
* Requires Private API to be enabled in BlueBubbles.
*/
async function createNewChatWithMessage(params: {
baseUrl: string;
password: string;
address: string;
message: string;
timeoutMs?: number;
allowPrivateNetwork?: boolean;
}): Promise<BlueBubblesSendResult> {
const result = await createChatForHandle({
baseUrl: params.baseUrl,
password: params.password,
address: params.address,
message: params.message,
timeoutMs: params.timeoutMs,
allowPrivateNetwork: params.allowPrivateNetwork,
});
return { messageId: result.messageId };
}
export async function sendMessageBlueBubbles(
to: string,
text: string,
opts: BlueBubblesSendOpts = {},
): Promise<BlueBubblesSendResult> {
const trimmedText = text ?? "";
if (!trimmedText.trim()) {
throw new Error("BlueBubbles send requires text");
}
// Strip markdown early and validate - ensures messages like "***" or "---" don't become empty
const strippedText = stripMarkdown(trimmedText);
if (!strippedText.trim()) {
throw new Error("BlueBubbles send requires text (message was empty after markdown removal)");
}
const { baseUrl, password, accountId, allowPrivateNetwork } = resolveBlueBubblesServerAccount({
cfg: opts.cfg ?? {},
accountId: opts.accountId,
serverUrl: opts.serverUrl,
password: opts.password,
});
let privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
const target = resolveBlueBubblesSendTarget(to);
const chatGuid = await resolveChatGuidForTarget({
baseUrl,
password,
timeoutMs: opts.timeoutMs,
target,
allowPrivateNetwork,
});
if (!chatGuid) {
// If target is a phone number/handle and no existing chat found,
// auto-create a new DM chat using the /api/v1/chat/new endpoint
if (target.kind === "handle") {
return createNewChatWithMessage({
baseUrl,
password,
address: target.address,
message: strippedText,
timeoutMs: opts.timeoutMs,
allowPrivateNetwork,
});
}
throw new Error(
"BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
);
}
const effectId = resolveEffectId(opts.effectId);
const wantsReplyThread = normalizeOptionalString(opts.replyToMessageGuid) !== undefined;
const wantsEffect = Boolean(effectId);
// Lazy refresh: when the cache has expired, fetch server info before
// making the decision. Originally scoped to reply/effect features (#43764)
// to avoid silent degradation after the 10-minute cache TTL expires. Now
// always fires on null status, because `isMacOS26OrHigher()` reads from
// the same cache and plain-text sends on macOS 26 need Private API too —
// without this, `forceOnMacOS26` silently falls back to broken AppleScript
// after TTL expiry or on a cold cache. (#64480, Greptile/Codex PR #69070)
if (privateApiStatus === null) {
try {
await fetchBlueBubblesServerInfo({
baseUrl,
password,
accountId,
timeoutMs: opts.timeoutMs ?? 5000,
allowPrivateNetwork,
});
privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
} catch {
// Refresh failed — proceed with null status (existing graceful degradation)
}
}
const privateApiDecision = resolvePrivateApiDecision({
privateApiStatus,
wantsReplyThread,
wantsEffect,
accountId,
});
if (privateApiDecision.throwEffectDisabledError) {
throw new Error(
"BlueBubbles send failed: reply/effect requires Private API, but it is disabled on the BlueBubbles server.",
);
}
if (privateApiDecision.warningMessage) {
warnBlueBubbles(privateApiDecision.warningMessage);
}
// Always set `method` explicitly. BB Server's behavior on an omitted
// `method` is version-dependent and silently drops on some setups (e.g.
// macOS without Private API — message lands in Messages.app locally but
// never reaches the phone). (#64480)
const payload: Record<string, unknown> = {
chatGuid,
tempGuid: crypto.randomUUID(),
message: strippedText,
method: privateApiDecision.canUsePrivateApi ? "private-api" : "apple-script",
};
// Add reply threading support
if (wantsReplyThread && privateApiDecision.canUsePrivateApi) {
payload.selectedMessageGuid = opts.replyToMessageGuid;
payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0;
}
// Add message effects support
if (effectId && privateApiDecision.canUsePrivateApi) {
payload.effectId = effectId;
}
const client = createBlueBubblesClient({
cfg: opts.cfg ?? {},
accountId: opts.accountId,
serverUrl: opts.serverUrl,
password: opts.password,
});
const res = await client.request({
method: "POST",
path: "/api/v1/message/text",
body: payload,
timeoutMs: opts.timeoutMs,
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`);
}
return parseBlueBubblesMessageResponse(res);
}

View file

@ -0,0 +1,37 @@
import {
buildChannelOutboundSessionRoute,
stripChannelTargetPrefix,
type ChannelOutboundSessionRouteParams,
} from "openclaw/plugin-sdk/channel-core";
import { parseBlueBubblesTarget } from "./targets.js";
export function resolveBlueBubblesOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
const stripped = stripChannelTargetPrefix(params.target, "bluebubbles");
if (!stripped) {
return null;
}
const parsed = parseBlueBubblesTarget(stripped);
const isGroup =
parsed.kind === "chat_id" || parsed.kind === "chat_guid" || parsed.kind === "chat_identifier";
const peerId =
parsed.kind === "chat_id"
? String(parsed.chatId)
: parsed.kind === "chat_guid"
? parsed.chatGuid
: parsed.kind === "chat_identifier"
? parsed.chatIdentifier
: parsed.to;
return buildChannelOutboundSessionRoute({
cfg: params.cfg,
agentId: params.agentId,
channel: "bluebubbles",
accountId: params.accountId,
peer: {
kind: isGroup ? "group" : "direct",
id: peerId,
},
chatType: isGroup ? "group" : "direct",
from: isGroup ? `group:${peerId}` : `bluebubbles:${peerId}`,
to: `bluebubbles:${stripped}`,
});
}

View file

@ -0,0 +1,99 @@
import {
addWildcardAllowFrom,
createSetupInputPresenceValidator,
normalizeAccountId,
patchScopedAccountConfig,
prepareScopedSetupConfig,
type ChannelSetupAdapter,
type DmPolicy,
type OpenClawConfig,
} from "openclaw/plugin-sdk/setup";
import { applyBlueBubblesConnectionConfig } from "./config-apply.js";
const channel = "bluebubbles" as const;
export function setBlueBubblesDmPolicy(
cfg: OpenClawConfig,
accountId: string,
dmPolicy: DmPolicy,
): OpenClawConfig {
const resolvedAccountId = normalizeAccountId(accountId);
const existingAllowFrom =
resolvedAccountId === "default"
? cfg.channels?.bluebubbles?.allowFrom
: ((
cfg.channels?.bluebubbles?.accounts?.[resolvedAccountId] as
| { allowFrom?: ReadonlyArray<string | number> }
| undefined
)?.allowFrom ?? cfg.channels?.bluebubbles?.allowFrom);
return patchScopedAccountConfig({
cfg,
channelKey: channel,
accountId: resolvedAccountId,
patch: {
dmPolicy,
...(dmPolicy === "open" ? { allowFrom: addWildcardAllowFrom(existingAllowFrom) } : {}),
},
ensureChannelEnabled: false,
ensureAccountEnabled: false,
});
}
export function setBlueBubblesAllowFrom(
cfg: OpenClawConfig,
accountId: string,
allowFrom: string[],
): OpenClawConfig {
return patchScopedAccountConfig({
cfg,
channelKey: channel,
accountId,
patch: { allowFrom },
ensureChannelEnabled: false,
ensureAccountEnabled: false,
});
}
export const blueBubblesSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
prepareScopedSetupConfig({
cfg,
channelKey: channel,
accountId,
name,
}),
validateInput: createSetupInputPresenceValidator({
validate: ({ input }) => {
if (!input.httpUrl && !input.password) {
return "BlueBubbles requires --http-url and --password.";
}
if (!input.httpUrl) {
return "BlueBubbles requires --http-url.";
}
if (!input.password) {
return "BlueBubbles requires --password.";
}
return null;
},
}),
applyAccountConfig: ({ cfg, accountId, input }) => {
const next = prepareScopedSetupConfig({
cfg,
channelKey: channel,
accountId,
name: input.name,
migrateBaseName: true,
});
return applyBlueBubblesConnectionConfig({
cfg: next,
accountId,
patch: {
serverUrl: input.httpUrl,
password: input.password,
webhookPath: input.webhookPath,
},
onlyDefinedFields: true,
});
},
};

View file

@ -0,0 +1,697 @@
import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import { describe, expect, it, vi } from "vitest";
import {
createSetupWizardAdapter,
createTestWizardPrompter,
runSetupWizardConfigure,
type WizardPrompter,
} from "../../../test/helpers/plugins/setup-wizard.js";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { BlueBubblesConfigSchema } from "./config-schema.js";
import {
resolveBlueBubblesGroupRequireMention,
resolveBlueBubblesGroupToolPolicy,
} from "./group-policy.js";
import { blueBubblesSetupAdapter, blueBubblesSetupWizard } from "./setup-surface.js";
import {
inferBlueBubblesTargetChatType,
isAllowedBlueBubblesSender,
looksLikeBlueBubblesExplicitTargetId,
looksLikeBlueBubblesTargetId,
normalizeBlueBubblesMessagingTarget,
parseBlueBubblesAllowTarget,
parseBlueBubblesTarget,
} from "./targets.js";
import { DEFAULT_WEBHOOK_PATH } from "./webhook-shared.js";
async function createBlueBubblesConfigureAdapter() {
const plugin = {
id: "bluebubbles",
meta: {
id: "bluebubbles",
label: "BlueBubbles",
selectionLabel: "BlueBubbles",
docsPath: "/channels/bluebubbles",
blurb: "iMessage via BlueBubbles",
},
capabilities: {
chatTypes: ["direct", "group"],
},
config: {
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
resolveAccount: adaptScopedAccountAccessor(resolveBlueBubblesAccount),
resolveAllowFrom: ({ cfg, accountId }: { cfg: unknown; accountId: string }) =>
resolveBlueBubblesAccount({
cfg: cfg as Parameters<typeof resolveBlueBubblesAccount>[0]["cfg"],
accountId,
}).config.allowFrom ?? [],
},
setup: blueBubblesSetupAdapter,
} as Parameters<typeof createSetupWizardAdapter>[0]["plugin"];
return createSetupWizardAdapter({
plugin,
wizard: blueBubblesSetupWizard,
});
}
async function runBlueBubblesConfigure(params: { cfg: unknown; prompter: WizardPrompter }) {
const adapter = await createBlueBubblesConfigureAdapter();
type ConfigureContext = Parameters<NonNullable<typeof adapter.configure>>[0];
return await runSetupWizardConfigure({
configure: adapter.configure,
cfg: params.cfg as ConfigureContext["cfg"],
runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"],
prompter: params.prompter,
});
}
describe("bluebubbles setup surface", () => {
it("preserves existing password SecretRef and keeps default webhook path", async () => {
const passwordRef = { source: "env", provider: "default", id: "BLUEBUBBLES_PASSWORD" };
const confirm = vi
.fn()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true);
const text = vi.fn();
const result = await runBlueBubblesConfigure({
cfg: {
channels: {
bluebubbles: {
enabled: true,
serverUrl: "http://127.0.0.1:1234",
password: passwordRef,
},
},
},
prompter: createTestWizardPrompter({ confirm, text }),
});
expect(result.cfg.channels?.bluebubbles?.password).toEqual(passwordRef);
expect(result.cfg.channels?.bluebubbles?.webhookPath).toBe(DEFAULT_WEBHOOK_PATH);
expect(text).not.toHaveBeenCalled();
});
it("applies a custom webhook path when requested", async () => {
const confirm = vi
.fn()
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true);
const text = vi.fn().mockResolvedValueOnce("/custom-bluebubbles");
const result = await runBlueBubblesConfigure({
cfg: {
channels: {
bluebubbles: {
enabled: true,
serverUrl: "http://127.0.0.1:1234",
password: "secret",
},
},
},
prompter: createTestWizardPrompter({ confirm, text }),
});
expect(result.cfg.channels?.bluebubbles?.webhookPath).toBe("/custom-bluebubbles");
expect(text).toHaveBeenCalledWith(
expect.objectContaining({
message: "Webhook path",
placeholder: DEFAULT_WEBHOOK_PATH,
}),
);
});
it("validates server URLs before accepting input", async () => {
const confirm = vi.fn().mockResolvedValueOnce(false);
const text = vi.fn().mockResolvedValueOnce("127.0.0.1:1234").mockResolvedValueOnce("secret");
await runBlueBubblesConfigure({
cfg: { channels: { bluebubbles: {} } },
prompter: createTestWizardPrompter({ confirm, text }),
});
const serverUrlPrompt = text.mock.calls[0]?.[0] as {
validate?: (value: string) => string | undefined;
};
expect(serverUrlPrompt.validate?.("bad url")).toBe("Invalid URL format");
expect(serverUrlPrompt.validate?.("127.0.0.1:1234")).toBeUndefined();
});
it("disables the channel through the setup wizard", async () => {
const next = blueBubblesSetupWizard.disable?.({
channels: {
bluebubbles: {
enabled: true,
serverUrl: "http://127.0.0.1:1234",
},
},
});
expect(next?.channels?.bluebubbles?.enabled).toBe(false);
});
it("reads the named-account DM policy instead of the channel root", async () => {
expect(
blueBubblesSetupWizard.dmPolicy?.getCurrent(
{
channels: {
bluebubbles: {
dmPolicy: "disabled",
accounts: {
work: {
serverUrl: "http://localhost:1234",
password: "secret",
dmPolicy: "allowlist",
},
},
},
},
},
"work",
),
).toBe("allowlist");
});
it("reports account-scoped config keys for named accounts", async () => {
expect(blueBubblesSetupWizard.dmPolicy?.resolveConfigKeys?.({}, "work")).toEqual({
policyKey: "channels.bluebubbles.accounts.work.dmPolicy",
allowFromKey: "channels.bluebubbles.accounts.work.allowFrom",
});
});
it("uses configured defaultAccount for omitted DM policy account context", async () => {
const cfg = {
channels: {
bluebubbles: {
defaultAccount: "work",
dmPolicy: "disabled",
allowFrom: ["user@example.com"],
accounts: {
work: {
serverUrl: "http://localhost:1234",
password: "secret",
dmPolicy: "allowlist",
},
},
},
},
} as OpenClawConfig;
expect(blueBubblesSetupWizard.dmPolicy?.getCurrent(cfg)).toBe("allowlist");
expect(blueBubblesSetupWizard.dmPolicy?.resolveConfigKeys?.(cfg)).toEqual({
policyKey: "channels.bluebubbles.accounts.work.dmPolicy",
allowFromKey: "channels.bluebubbles.accounts.work.allowFrom",
});
const next = blueBubblesSetupWizard.dmPolicy?.setPolicy(cfg, "open");
const workAccount = next?.channels?.bluebubbles?.accounts?.work as
| {
dmPolicy?: string;
}
| undefined;
expect(next?.channels?.bluebubbles?.dmPolicy).toBe("disabled");
expect(workAccount?.dmPolicy).toBe("open");
});
it("uses configured defaultAccount when accountId is omitted in account resolution", async () => {
const resolved = resolveBlueBubblesAccount({
cfg: {
channels: {
bluebubbles: {
defaultAccount: "work",
serverUrl: "http://localhost:3000",
password: "top-secret",
accounts: {
work: {
serverUrl: "http://localhost:1234",
password: "secret",
name: "Work",
},
},
},
},
} as OpenClawConfig,
});
expect(resolved.accountId).toBe("work");
expect(resolved.name).toBe("Work");
expect(resolved.baseUrl).toBe("http://localhost:1234");
expect(resolved.configured).toBe(true);
});
it("uses configured defaultAccount for omitted setup configured state", async () => {
const configured = await blueBubblesSetupWizard.status.resolveConfigured({
cfg: {
channels: {
bluebubbles: {
defaultAccount: "work",
serverUrl: "http://localhost:3000",
password: "top-secret",
accounts: {
alerts: {
serverUrl: "http://localhost:4000",
password: "alerts-secret",
},
work: {
serverUrl: "",
password: "",
},
},
},
},
} as OpenClawConfig,
});
expect(configured).toBe(false);
});
it('writes open policy state to the named account and preserves inherited allowFrom with "*"', async () => {
const next = blueBubblesSetupWizard.dmPolicy?.setPolicy(
{
channels: {
bluebubbles: {
allowFrom: ["user@example.com"],
accounts: {
work: {
serverUrl: "http://localhost:1234",
password: "secret",
},
},
},
},
},
"open",
"work",
);
const workAccount = next?.channels?.bluebubbles?.accounts?.work as
| {
dmPolicy?: string;
allowFrom?: string[];
}
| undefined;
expect(next?.channels?.bluebubbles?.dmPolicy).toBeUndefined();
expect(workAccount?.dmPolicy).toBe("open");
expect(workAccount?.allowFrom).toEqual(["user@example.com", "*"]);
});
});
describe("resolveBlueBubblesAccount", () => {
it("treats SecretRef passwords as configured when serverUrl exists", () => {
const resolved = resolveBlueBubblesAccount({
cfg: {
channels: {
bluebubbles: {
enabled: true,
serverUrl: "http://localhost:1234",
password: {
source: "env",
provider: "default",
id: "BLUEBUBBLES_PASSWORD",
},
},
},
},
});
expect(resolved.configured).toBe(true);
expect(resolved.baseUrl).toBe("http://localhost:1234");
});
it("strips stale legacy private-network aliases after canonical normalization", () => {
const resolved = resolveBlueBubblesAccount({
cfg: {
channels: {
bluebubbles: {
network: {
allowPrivateNetwork: true,
},
accounts: {
work: {
serverUrl: "http://localhost:1234",
password: "secret", // pragma: allowlist secret
network: {
dangerouslyAllowPrivateNetwork: false,
},
},
},
},
},
},
accountId: "work",
});
expect(resolved.config.network).toEqual({
dangerouslyAllowPrivateNetwork: false,
});
expect("allowPrivateNetwork" in resolved.config).toBe(false);
expect(isPrivateNetworkOptInEnabled(resolved.config)).toBe(false);
});
});
describe("BlueBubblesConfigSchema", () => {
it("accepts account config when serverUrl and password are both set", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
serverUrl: "http://localhost:1234",
password: "secret", // pragma: allowlist secret
});
expect(parsed.success).toBe(true);
});
it("accepts SecretRef password when serverUrl is set", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
serverUrl: "http://localhost:1234",
password: {
source: "env",
provider: "default",
id: "BLUEBUBBLES_PASSWORD",
},
});
expect(parsed.success).toBe(true);
});
it("requires password when top-level serverUrl is configured", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
serverUrl: "http://localhost:1234",
});
expect(parsed.success).toBe(false);
if (parsed.success) {
return;
}
expect(parsed.error.issues[0]?.path).toEqual(["password"]);
expect(parsed.error.issues[0]?.message).toBe(
"password is required when serverUrl is configured",
);
});
it("requires password when account serverUrl is configured", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
accounts: {
work: {
serverUrl: "http://localhost:1234",
},
},
});
expect(parsed.success).toBe(false);
if (parsed.success) {
return;
}
expect(parsed.error.issues[0]?.path).toEqual(["accounts", "work", "password"]);
expect(parsed.error.issues[0]?.message).toBe(
"password is required when serverUrl is configured",
);
});
it("allows password omission when serverUrl is not configured", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
accounts: {
work: {
name: "Work iMessage",
},
},
});
expect(parsed.success).toBe(true);
});
it("defaults enrichGroupParticipantsFromContacts to true", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
serverUrl: "http://localhost:1234",
password: "secret", // pragma: allowlist secret
});
expect(parsed.success).toBe(true);
if (!parsed.success) {
return;
}
expect(parsed.data.enrichGroupParticipantsFromContacts).toBe(true);
});
it("defaults account enrichGroupParticipantsFromContacts to true", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
accounts: {
work: {
serverUrl: "http://localhost:1234",
password: "secret", // pragma: allowlist secret
},
},
});
expect(parsed.success).toBe(true);
if (!parsed.success) {
return;
}
const accountConfig = (
parsed.data as { accounts?: { work?: { enrichGroupParticipantsFromContacts?: boolean } } }
).accounts?.work;
expect(accountConfig?.enrichGroupParticipantsFromContacts).toBe(true);
});
});
describe("bluebubbles group policy", () => {
it("uses generic channel group policy helpers", () => {
const cfg = {
channels: {
bluebubbles: {
groups: {
"chat:primary": {
requireMention: false,
tools: { deny: ["exec"] },
},
"*": {
requireMention: true,
tools: { allow: ["message.send"] },
},
},
},
},
} as any;
expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:primary" })).toBe(false);
expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:other" })).toBe(true);
expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:primary" })).toEqual({
deny: ["exec"],
});
expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:other" })).toEqual({
allow: ["message.send"],
});
});
});
describe("normalizeBlueBubblesMessagingTarget", () => {
it("normalizes chat_guid targets", () => {
expect(normalizeBlueBubblesMessagingTarget("chat_guid:ABC-123")).toBe("chat_guid:ABC-123");
});
it("normalizes group numeric targets to chat_id", () => {
expect(normalizeBlueBubblesMessagingTarget("group:123")).toBe("chat_id:123");
});
it("strips provider prefix and normalizes handles", () => {
expect(normalizeBlueBubblesMessagingTarget("bluebubbles:imessage:User@Example.com")).toBe(
"imessage:user@example.com",
);
});
it("extracts handle from DM chat_guid for cross-context matching", () => {
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;+19257864429")).toBe(
"+19257864429",
);
expect(normalizeBlueBubblesMessagingTarget("chat_guid:SMS;-;+15551234567")).toBe(
"+15551234567",
);
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;user@example.com")).toBe(
"user@example.com",
);
});
it("preserves group chat_guid format", () => {
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;+;chat123456789")).toBe(
"chat_guid:iMessage;+;chat123456789",
);
});
it("normalizes raw chat_guid values", () => {
expect(normalizeBlueBubblesMessagingTarget("iMessage;+;chat660250192681427962")).toBe(
"chat_guid:iMessage;+;chat660250192681427962",
);
expect(normalizeBlueBubblesMessagingTarget("iMessage;-;+19257864429")).toBe("+19257864429");
});
it("normalizes chat<digits> pattern to chat_identifier format", () => {
expect(normalizeBlueBubblesMessagingTarget("chat660250192681427962")).toBe(
"chat_identifier:chat660250192681427962",
);
expect(normalizeBlueBubblesMessagingTarget("chat123")).toBe("chat_identifier:chat123");
expect(normalizeBlueBubblesMessagingTarget("Chat456789")).toBe("chat_identifier:Chat456789");
});
it("normalizes UUID/hex chat identifiers", () => {
expect(normalizeBlueBubblesMessagingTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(
"chat_identifier:8b9c1a10536d4d86a336ea03ab7151cc",
);
expect(normalizeBlueBubblesMessagingTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(
"chat_identifier:1C2D3E4F-1234-5678-9ABC-DEF012345678",
);
});
});
describe("looksLikeBlueBubblesTargetId", () => {
it("accepts chat targets", () => {
expect(looksLikeBlueBubblesTargetId("chat_guid:ABC-123")).toBe(true);
});
it("accepts email handles", () => {
expect(looksLikeBlueBubblesTargetId("user@example.com")).toBe(true);
});
it("accepts phone numbers with punctuation", () => {
expect(looksLikeBlueBubblesTargetId("+1 (555) 123-4567")).toBe(true);
});
it("accepts raw chat_guid values", () => {
expect(looksLikeBlueBubblesTargetId("iMessage;+;chat660250192681427962")).toBe(true);
});
it("accepts chat<digits> pattern as chat_id", () => {
expect(looksLikeBlueBubblesTargetId("chat660250192681427962")).toBe(true);
expect(looksLikeBlueBubblesTargetId("chat123")).toBe(true);
expect(looksLikeBlueBubblesTargetId("Chat456789")).toBe(true);
});
it("accepts UUID/hex chat identifiers", () => {
expect(looksLikeBlueBubblesTargetId("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(true);
expect(looksLikeBlueBubblesTargetId("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(true);
});
it("rejects display names", () => {
expect(looksLikeBlueBubblesTargetId("Jane Doe")).toBe(false);
});
});
describe("looksLikeBlueBubblesExplicitTargetId", () => {
it("treats explicit chat targets as immediate ids", () => {
expect(looksLikeBlueBubblesExplicitTargetId("chat_guid:ABC-123")).toBe(true);
expect(looksLikeBlueBubblesExplicitTargetId("imessage:+15551234567")).toBe(true);
});
it("prefers directory fallback for bare handles and phone numbers", () => {
expect(looksLikeBlueBubblesExplicitTargetId("+1 (555) 123-4567")).toBe(false);
expect(looksLikeBlueBubblesExplicitTargetId("user@example.com")).toBe(false);
});
});
describe("inferBlueBubblesTargetChatType", () => {
it("infers direct chat for handles and dm chat_guids", () => {
expect(inferBlueBubblesTargetChatType("+15551234567")).toBe("direct");
expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;-;+15551234567")).toBe("direct");
});
it("infers group chat for explicit group targets", () => {
expect(inferBlueBubblesTargetChatType("chat_id:123")).toBe("group");
expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;+;chat123")).toBe("group");
});
});
describe("parseBlueBubblesTarget", () => {
it("parses chat<digits> pattern as chat_identifier", () => {
expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat660250192681427962",
});
expect(parseBlueBubblesTarget("chat123")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat123",
});
expect(parseBlueBubblesTarget("Chat456789")).toEqual({
kind: "chat_identifier",
chatIdentifier: "Chat456789",
});
});
it("parses UUID/hex chat identifiers as chat_identifier", () => {
expect(parseBlueBubblesTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
kind: "chat_identifier",
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
});
expect(parseBlueBubblesTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
kind: "chat_identifier",
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
});
});
it("parses explicit chat_id: prefix", () => {
expect(parseBlueBubblesTarget("chat_id:123")).toEqual({ kind: "chat_id", chatId: 123 });
});
it("parses phone numbers as handles", () => {
expect(parseBlueBubblesTarget("+19257864429")).toEqual({
kind: "handle",
to: "+19257864429",
service: "auto",
});
});
it("parses raw chat_guid format", () => {
expect(parseBlueBubblesTarget("iMessage;+;chat660250192681427962")).toEqual({
kind: "chat_guid",
chatGuid: "iMessage;+;chat660250192681427962",
});
});
});
describe("parseBlueBubblesAllowTarget", () => {
it("parses chat<digits> pattern as chat_identifier", () => {
expect(parseBlueBubblesAllowTarget("chat660250192681427962")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat660250192681427962",
});
expect(parseBlueBubblesAllowTarget("chat123")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat123",
});
});
it("parses UUID/hex chat identifiers as chat_identifier", () => {
expect(parseBlueBubblesAllowTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
kind: "chat_identifier",
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
});
expect(parseBlueBubblesAllowTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
kind: "chat_identifier",
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
});
});
it("parses explicit chat_id: prefix", () => {
expect(parseBlueBubblesAllowTarget("chat_id:456")).toEqual({ kind: "chat_id", chatId: 456 });
});
it("parses phone numbers as handles", () => {
expect(parseBlueBubblesAllowTarget("+19257864429")).toEqual({
kind: "handle",
handle: "+19257864429",
});
});
});
describe("isAllowedBlueBubblesSender", () => {
it("denies when allowFrom is empty", () => {
const allowed = isAllowedBlueBubblesSender({
allowFrom: [],
sender: "+15551234567",
});
expect(allowed).toBe(false);
});
it("allows wildcard entries", () => {
const allowed = isAllowedBlueBubblesSender({
allowFrom: ["*"],
sender: "+15551234567",
});
expect(allowed).toBe(true);
});
});

View file

@ -0,0 +1,299 @@
import {
createAllowFromSection,
createPromptParsedAllowFromForAccount,
createStandardChannelSetupStatus,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
type OpenClawConfig,
} from "openclaw/plugin-sdk/setup";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { resolveBlueBubblesAccount, resolveDefaultBlueBubblesAccountId } from "./accounts.js";
import { applyBlueBubblesConnectionConfig } from "./config-apply.js";
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
import {
blueBubblesSetupAdapter,
setBlueBubblesAllowFrom,
setBlueBubblesDmPolicy,
} from "./setup-core.js";
import { parseBlueBubblesAllowTarget } from "./targets.js";
import { normalizeBlueBubblesServerUrl } from "./types.js";
import { DEFAULT_WEBHOOK_PATH } from "./webhook-shared.js";
const channel = "bluebubbles" as const;
const CONFIGURE_CUSTOM_WEBHOOK_FLAG = "__bluebubblesConfigureCustomWebhookPath";
function parseBlueBubblesAllowFromInput(raw: string): string[] {
return raw
.split(/[\n,]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
function validateBlueBubblesAllowFromEntry(value: string): string | null {
try {
if (value === "*") {
return value;
}
const parsed = parseBlueBubblesAllowTarget(value);
if (parsed.kind === "handle" && !parsed.handle) {
return null;
}
return normalizeOptionalString(value) ?? null;
} catch {
return null;
}
}
const promptBlueBubblesAllowFrom = createPromptParsedAllowFromForAccount({
defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg),
noteTitle: "BlueBubbles allowlist",
noteLines: [
"Allowlist BlueBubbles DMs by handle or chat target.",
"Examples:",
"- +15555550123",
"- user@example.com",
"- chat_id:123",
"- chat_guid:iMessage;-;+15555550123",
"Multiple entries: comma- or newline-separated.",
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
],
message: "BlueBubbles allowFrom (handle or chat_id)",
placeholder: "+15555550123, user@example.com, chat_id:123",
parseEntries: (raw) => {
const entries = parseBlueBubblesAllowFromInput(raw);
for (const entry of entries) {
if (!validateBlueBubblesAllowFromEntry(entry)) {
return { entries: [], error: `Invalid entry: ${entry}` };
}
}
return { entries };
},
getExistingAllowFrom: ({ cfg, accountId }) =>
resolveBlueBubblesAccount({ cfg, accountId }).config.allowFrom ?? [],
applyAllowFrom: ({ cfg, accountId, allowFrom }) =>
setBlueBubblesAllowFrom(cfg, accountId, allowFrom),
});
function validateBlueBubblesServerUrlInput(value: unknown): string | undefined {
const trimmed = normalizeOptionalString(value) ?? "";
if (!trimmed) {
return "Required";
}
try {
const normalized = normalizeBlueBubblesServerUrl(trimmed);
if (!URL.canParse(normalized)) {
return "Invalid URL format";
}
return undefined;
} catch {
return "Invalid URL format";
}
}
function applyBlueBubblesSetupPatch(
cfg: OpenClawConfig,
accountId: string,
patch: {
serverUrl?: string;
password?: unknown;
webhookPath?: string;
},
): OpenClawConfig {
return applyBlueBubblesConnectionConfig({
cfg,
accountId,
patch,
onlyDefinedFields: true,
accountEnabled: "preserve-or-true",
});
}
function validateBlueBubblesWebhookPath(value: string): string | undefined {
const trimmed = value.trim();
if (!trimmed) {
return "Required";
}
if (!trimmed.startsWith("/")) {
return "Path must start with /";
}
return undefined;
}
const dmPolicy: ChannelSetupDmPolicy = {
label: "BlueBubbles",
channel,
policyKey: "channels.bluebubbles.dmPolicy",
allowFromKey: "channels.bluebubbles.allowFrom",
resolveConfigKeys: (cfg, accountId) =>
(accountId ?? resolveDefaultBlueBubblesAccountId(cfg)) !== DEFAULT_ACCOUNT_ID
? {
policyKey: `channels.bluebubbles.accounts.${accountId ?? resolveDefaultBlueBubblesAccountId(cfg)}.dmPolicy`,
allowFromKey: `channels.bluebubbles.accounts.${accountId ?? resolveDefaultBlueBubblesAccountId(cfg)}.allowFrom`,
}
: {
policyKey: "channels.bluebubbles.dmPolicy",
allowFromKey: "channels.bluebubbles.allowFrom",
},
getCurrent: (cfg, accountId) =>
resolveBlueBubblesAccount({
cfg,
accountId: accountId ?? resolveDefaultBlueBubblesAccountId(cfg),
}).config.dmPolicy ?? "pairing",
setPolicy: (cfg, policy, accountId) =>
setBlueBubblesDmPolicy(cfg, accountId ?? resolveDefaultBlueBubblesAccountId(cfg), policy),
promptAllowFrom: promptBlueBubblesAllowFrom,
};
export const blueBubblesSetupWizard: ChannelSetupWizard = {
channel,
stepOrder: "text-first",
status: {
...createStandardChannelSetupStatus({
channelLabel: "BlueBubbles",
configuredLabel: "configured",
unconfiguredLabel: "needs setup",
configuredHint: "configured",
unconfiguredHint: "iMessage via BlueBubbles app",
configuredScore: 1,
unconfiguredScore: 0,
includeStatusLine: true,
resolveConfigured: ({ cfg, accountId }) =>
resolveBlueBubblesAccount({ cfg, accountId }).configured,
}),
resolveSelectionHint: ({ configured }) =>
configured ? "configured" : "iMessage via BlueBubbles app",
},
prepare: async ({ cfg, accountId, prompter, credentialValues }) => {
const existingWebhookPath = normalizeOptionalString(
resolveBlueBubblesAccount({ cfg, accountId }).config.webhookPath,
);
const wantsCustomWebhook = await prompter.confirm({
message: `Configure a custom webhook path? (default: ${DEFAULT_WEBHOOK_PATH})`,
initialValue: Boolean(existingWebhookPath && existingWebhookPath !== DEFAULT_WEBHOOK_PATH),
});
return {
cfg: wantsCustomWebhook
? cfg
: applyBlueBubblesSetupPatch(cfg, accountId, { webhookPath: DEFAULT_WEBHOOK_PATH }),
credentialValues: {
...credentialValues,
[CONFIGURE_CUSTOM_WEBHOOK_FLAG]: wantsCustomWebhook ? "1" : "0",
},
};
},
credentials: [
{
inputKey: "password",
providerHint: channel,
credentialLabel: "server password",
helpTitle: "BlueBubbles password",
helpLines: [
"Enter the BlueBubbles server password.",
"Find this in the BlueBubbles Server app under Settings.",
],
envPrompt: "",
keepPrompt: "BlueBubbles password already set. Keep it?",
inputPrompt: "BlueBubbles password",
inspect: ({ cfg, accountId }) => {
const existingPassword = resolveBlueBubblesAccount({ cfg, accountId }).config.password;
return {
accountConfigured: resolveBlueBubblesAccount({ cfg, accountId }).configured,
hasConfiguredValue: hasConfiguredSecretInput(existingPassword),
resolvedValue: normalizeSecretInputString(existingPassword) ?? undefined,
};
},
applySet: async ({ cfg, accountId, value }) =>
applyBlueBubblesSetupPatch(cfg, accountId, {
password: value,
}),
},
],
textInputs: [
{
inputKey: "httpUrl",
message: "BlueBubbles server URL",
placeholder: "http://192.168.1.100:1234",
helpTitle: "BlueBubbles server URL",
helpLines: [
"Enter the BlueBubbles server URL (e.g., http://192.168.1.100:1234).",
"Find this in the BlueBubbles Server app under Connection.",
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
],
currentValue: ({ cfg, accountId }) =>
normalizeOptionalString(resolveBlueBubblesAccount({ cfg, accountId }).config.serverUrl),
validate: ({ value }) => validateBlueBubblesServerUrlInput(value),
normalizeValue: ({ value }) => value.trim(),
applySet: async ({ cfg, accountId, value }) =>
applyBlueBubblesSetupPatch(cfg, accountId, {
serverUrl: value,
}),
},
{
inputKey: "webhookPath",
message: "Webhook path",
placeholder: DEFAULT_WEBHOOK_PATH,
currentValue: ({ cfg, accountId }) => {
const value = normalizeOptionalString(
resolveBlueBubblesAccount({ cfg, accountId }).config.webhookPath,
);
return value && value !== DEFAULT_WEBHOOK_PATH ? value : undefined;
},
shouldPrompt: ({ credentialValues }) =>
credentialValues[CONFIGURE_CUSTOM_WEBHOOK_FLAG] === "1",
validate: ({ value }) => validateBlueBubblesWebhookPath(value),
normalizeValue: ({ value }) => value.trim(),
applySet: async ({ cfg, accountId, value }) =>
applyBlueBubblesSetupPatch(cfg, accountId, {
webhookPath: value,
}),
},
],
completionNote: {
title: "BlueBubbles next steps",
lines: [
"Configure the webhook URL in BlueBubbles Server:",
"1. Open BlueBubbles Server -> Settings -> Webhooks",
"2. Add your OpenClaw gateway URL + webhook path",
` Example: https://your-gateway-host:3000${DEFAULT_WEBHOOK_PATH}`,
"3. Enable the webhook and save",
"",
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
],
},
dmPolicy,
allowFrom: createAllowFromSection({
helpTitle: "BlueBubbles allowlist",
helpLines: [
"Allowlist BlueBubbles DMs by handle or chat target.",
"Examples:",
"- +15555550123",
"- user@example.com",
"- chat_id:123",
"- chat_guid:iMessage;-;+15555550123",
"Multiple entries: comma- or newline-separated.",
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
],
message: "BlueBubbles allowFrom (handle or chat_id)",
placeholder: "+15555550123, user@example.com, chat_id:123",
invalidWithoutCredentialNote:
"Use a BlueBubbles handle or chat target like +15555550123 or chat_id:123.",
parseInputs: parseBlueBubblesAllowFromInput,
parseId: (raw) => validateBlueBubblesAllowFromEntry(raw),
apply: async ({ cfg, accountId, allowFrom }) =>
setBlueBubblesAllowFrom(cfg, accountId, allowFrom),
}),
disable: (cfg) => ({
...cfg,
channels: {
...cfg.channels,
bluebubbles: {
...cfg.channels?.bluebubbles,
enabled: false,
},
},
}),
};
export { blueBubblesSetupAdapter };

View file

@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { collectBlueBubblesStatusIssues } from "./status-issues.js";
describe("collectBlueBubblesStatusIssues", () => {
it("reports unconfigured enabled accounts", () => {
const issues = collectBlueBubblesStatusIssues([
{
accountId: "default",
enabled: true,
configured: false,
},
]);
expect(issues).toEqual([
expect.objectContaining({
channel: "bluebubbles",
accountId: "default",
kind: "config",
}),
]);
});
it("reports probe failure and runtime error for configured running accounts", () => {
const issues = collectBlueBubblesStatusIssues([
{
accountId: "work",
enabled: true,
configured: true,
running: true,
lastError: "timeout",
probe: {
ok: false,
status: 503,
},
},
]);
expect(issues).toHaveLength(2);
expect(issues[0]).toEqual(
expect.objectContaining({
channel: "bluebubbles",
accountId: "work",
kind: "runtime",
}),
);
expect(issues[1]).toEqual(
expect.objectContaining({
channel: "bluebubbles",
accountId: "work",
kind: "runtime",
message: "Channel error: timeout",
}),
);
});
});

View file

@ -0,0 +1,102 @@
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract";
import { collectIssuesForEnabledAccounts } from "openclaw/plugin-sdk/status-helpers";
import { asRecord } from "./monitor-normalize.js";
type BlueBubblesAccountStatus = {
accountId?: unknown;
enabled?: unknown;
configured?: unknown;
running?: unknown;
baseUrl?: unknown;
lastError?: unknown;
probe?: unknown;
};
type BlueBubblesProbeResult = {
ok?: boolean;
status?: number | null;
error?: string | null;
};
function asString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
function readBlueBubblesAccountStatus(
value: ChannelAccountSnapshot,
): BlueBubblesAccountStatus | null {
const record = asRecord(value);
if (!record) {
return null;
}
return {
accountId: record.accountId,
enabled: record.enabled,
configured: record.configured,
running: record.running,
baseUrl: record.baseUrl,
lastError: record.lastError,
probe: record.probe,
};
}
function readBlueBubblesProbeResult(value: unknown): BlueBubblesProbeResult | null {
const record = asRecord(value);
if (!record) {
return null;
}
return {
ok: typeof record.ok === "boolean" ? record.ok : undefined,
status: typeof record.status === "number" ? record.status : null,
error: asString(record.error) ?? null,
};
}
export function collectBlueBubblesStatusIssues(accounts: ChannelAccountSnapshot[]) {
return collectIssuesForEnabledAccounts({
accounts,
readAccount: readBlueBubblesAccountStatus,
collectIssues: ({ account, accountId, issues }) => {
const configured = account.configured === true;
const running = account.running === true;
const lastError = asString(account.lastError);
const probe = readBlueBubblesProbeResult(account.probe);
if (!configured) {
issues.push({
channel: "bluebubbles",
accountId,
kind: "config",
message: "Not configured (missing serverUrl or password).",
fix: "Run: openclaw channels add bluebubbles --http-url <server-url> --password <password>",
});
return;
}
if (probe && probe.ok === false) {
const errorDetail = probe.error
? `: ${probe.error}`
: probe.status
? ` (HTTP ${probe.status})`
: "";
issues.push({
channel: "bluebubbles",
accountId,
kind: "runtime",
message: `BlueBubbles server unreachable${errorDetail}`,
fix: "Check that the BlueBubbles server is running and accessible. Verify serverUrl and password in your config.",
});
}
if (running && lastError) {
issues.push({
channel: "bluebubbles",
accountId,
kind: "runtime",
message: `Channel error: ${lastError}`,
fix: "Check gateway logs for details. If the webhook is failing, verify the webhook URL is configured in BlueBubbles server settings.",
});
}
},
});
}

View file

@ -0,0 +1,428 @@
import { isAllowedParsedChatSender } from "openclaw/plugin-sdk/allow-from";
import {
parseChatAllowTargetPrefixes,
parseChatTargetPrefixesOrThrow,
type ParsedChatTarget,
resolveServicePrefixedAllowTarget,
resolveServicePrefixedTarget,
} from "openclaw/plugin-sdk/channel-targets";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
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 = ParsedChatTarget | { kind: "handle"; handle: string };
const CHAT_ID_PREFIXES = ["chat_id:", "chatid:", "chat:"];
const CHAT_GUID_PREFIXES = ["chat_guid:", "chatguid:", "guid:"];
const CHAT_IDENTIFIER_PREFIXES = ["chat_identifier:", "chatidentifier:", "chatident:"];
const SERVICE_PREFIXES: Array<{ prefix: string; service: BlueBubblesService }> = [
{ prefix: "imessage:", service: "imessage" },
{ prefix: "sms:", service: "sms" },
{ prefix: "auto:", service: "auto" },
];
const CHAT_IDENTIFIER_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const CHAT_IDENTIFIER_HEX_RE = /^[0-9a-f]{24,64}$/i;
function parseRawChatGuid(value: string): string | null {
const trimmed = normalizeOptionalString(value);
if (!trimmed) {
return null;
}
const parts = trimmed.split(";");
if (parts.length !== 3) {
return null;
}
const service = normalizeOptionalString(parts[0]);
const separator = normalizeOptionalString(parts[1]);
const identifier = normalizeOptionalString(parts[2]);
if (!service || !identifier) {
return null;
}
if (separator !== "+" && separator !== "-") {
return null;
}
return `${service};${separator};${identifier}`;
}
function stripPrefix(value: string, prefix: string): string {
return value.slice(prefix.length).trim();
}
function stripBlueBubblesPrefix(value: string): string {
const trimmed = normalizeOptionalString(value) ?? "";
if (!trimmed) {
return "";
}
if (!normalizeLowercaseStringOrEmpty(trimmed).startsWith("bluebubbles:")) {
return trimmed;
}
return trimmed.slice("bluebubbles:".length).trim();
}
function looksLikeRawChatIdentifier(value: string): boolean {
const trimmed = normalizeOptionalString(value);
if (!trimmed) {
return false;
}
if (/^chat\d+$/i.test(trimmed)) {
return true;
}
return CHAT_IDENTIFIER_UUID_RE.test(trimmed) || CHAT_IDENTIFIER_HEX_RE.test(trimmed);
}
function parseGroupTarget(params: {
trimmed: string;
lower: string;
requireValue: boolean;
}): { kind: "chat_id"; chatId: number } | { kind: "chat_guid"; chatGuid: string } | null {
if (!params.lower.startsWith("group:")) {
return null;
}
const value = stripPrefix(params.trimmed, "group:");
const chatId = Number.parseInt(value, 10);
if (Number.isFinite(chatId)) {
return { kind: "chat_id", chatId };
}
if (value) {
return { kind: "chat_guid", chatGuid: value };
}
if (params.requireValue) {
throw new Error("group target is required");
}
return null;
}
function parseRawChatIdentifierTarget(
trimmed: string,
): { kind: "chat_identifier"; chatIdentifier: string } | null {
if (/^chat\d+$/i.test(trimmed)) {
return { kind: "chat_identifier", chatIdentifier: trimmed };
}
if (looksLikeRawChatIdentifier(trimmed)) {
return { kind: "chat_identifier", chatIdentifier: trimmed };
}
return null;
}
export function normalizeBlueBubblesHandle(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return "";
}
const lowered = normalizeLowercaseStringOrEmpty(trimmed);
if (lowered.startsWith("imessage:")) {
return normalizeBlueBubblesHandle(trimmed.slice(9));
}
if (lowered.startsWith("sms:")) {
return normalizeBlueBubblesHandle(trimmed.slice(4));
}
if (lowered.startsWith("auto:")) {
return normalizeBlueBubblesHandle(trimmed.slice(5));
}
if (trimmed.includes("@")) {
return normalizeLowercaseStringOrEmpty(trimmed);
}
return trimmed.replace(/\s+/g, "");
}
/**
* Extracts the handle from a chat_guid if it's a DM (1:1 chat).
* BlueBubbles chat_guid format for DM: "service;-;handle" (e.g., "iMessage;-;+19257864429")
* Group chat format: "service;+;groupId" (has "+" instead of "-")
*/
export function extractHandleFromChatGuid(chatGuid: string): string | null {
const parts = chatGuid.split(";");
// DM format: service;-;handle (3 parts, middle is "-")
if (parts.length === 3 && parts[1] === "-") {
const handle = normalizeOptionalString(parts[2]);
if (handle) {
return normalizeBlueBubblesHandle(handle);
}
}
return null;
}
export function normalizeBlueBubblesMessagingTarget(raw: string): string | undefined {
let trimmed = raw.trim();
if (!trimmed) {
return undefined;
}
trimmed = stripBlueBubblesPrefix(trimmed);
if (!trimmed) {
return undefined;
}
try {
const parsed = parseBlueBubblesTarget(trimmed);
if (parsed.kind === "chat_id") {
return `chat_id:${parsed.chatId}`;
}
if (parsed.kind === "chat_guid") {
// For DM chat_guids, normalize to just the handle for easier comparison.
// This allows "chat_guid:iMessage;-;+1234567890" to match "+1234567890".
const handle = extractHandleFromChatGuid(parsed.chatGuid);
if (handle) {
return handle;
}
// For group chats or unrecognized formats, keep the full chat_guid
return `chat_guid:${parsed.chatGuid}`;
}
if (parsed.kind === "chat_identifier") {
return `chat_identifier:${parsed.chatIdentifier}`;
}
const handle = normalizeBlueBubblesHandle(parsed.to);
if (!handle) {
return undefined;
}
return parsed.service === "auto" ? handle : `${parsed.service}:${handle}`;
} catch {
return trimmed;
}
}
export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
const candidate = stripBlueBubblesPrefix(trimmed);
if (!candidate) {
return false;
}
if (parseRawChatGuid(candidate)) {
return true;
}
const lowered = normalizeLowercaseStringOrEmpty(candidate);
if (/^(imessage|sms|auto):/.test(lowered)) {
return true;
}
if (
/^(chat_id|chatid|chat|chat_guid|chatguid|guid|chat_identifier|chatidentifier|chatident|group):/.test(
lowered,
)
) {
return true;
}
// Recognize chat<digits> patterns (e.g., "chat660250192681427962") as chat IDs
if (/^chat\d+$/i.test(candidate)) {
return true;
}
if (looksLikeRawChatIdentifier(candidate)) {
return true;
}
if (candidate.includes("@")) {
return true;
}
const digitsOnly = candidate.replace(/[\s().-]/g, "");
if (/^\+?\d{3,}$/.test(digitsOnly)) {
return true;
}
if (normalized) {
const normalizedTrimmed = normalizeOptionalString(normalized);
if (!normalizedTrimmed) {
return false;
}
const normalizedLower = normalizeLowercaseStringOrEmpty(normalizedTrimmed);
if (
/^(imessage|sms|auto):/.test(normalizedLower) ||
/^(chat_id|chat_guid|chat_identifier):/.test(normalizedLower)
) {
return true;
}
}
return false;
}
export function looksLikeBlueBubblesExplicitTargetId(raw: string, normalized?: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
const candidate = stripBlueBubblesPrefix(trimmed);
if (!candidate) {
return false;
}
const lowered = normalizeLowercaseStringOrEmpty(candidate);
if (/^(imessage|sms|auto):/.test(lowered)) {
return true;
}
if (
/^(chat_id|chatid|chat|chat_guid|chatguid|guid|chat_identifier|chatidentifier|chatident|group):/.test(
lowered,
)
) {
return true;
}
if (parseRawChatGuid(candidate) || looksLikeRawChatIdentifier(candidate)) {
return true;
}
if (normalized) {
const normalizedTrimmed = normalized.trim();
if (!normalizedTrimmed) {
return false;
}
const normalizedLower = normalizeLowercaseStringOrEmpty(normalizedTrimmed);
if (
/^(imessage|sms|auto):/.test(normalizedLower) ||
/^(chat_id|chat_guid|chat_identifier):/.test(normalizedLower)
) {
return true;
}
}
return false;
}
export function inferBlueBubblesTargetChatType(raw: string): "direct" | "group" | undefined {
try {
const parsed = parseBlueBubblesTarget(raw);
if (parsed.kind === "handle") {
return "direct";
}
if (parsed.kind === "chat_guid") {
return parsed.chatGuid.includes(";+;") ? "group" : "direct";
}
if (parsed.kind === "chat_id" || parsed.kind === "chat_identifier") {
return "group";
}
} catch {
return undefined;
}
return undefined;
}
export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
const trimmed = stripBlueBubblesPrefix(raw);
if (!trimmed) {
throw new Error("BlueBubbles target is required");
}
const lower = normalizeLowercaseStringOrEmpty(trimmed);
const servicePrefixed = resolveServicePrefixedTarget({
trimmed,
lower,
servicePrefixes: SERVICE_PREFIXES,
isChatTarget: (remainderLower) =>
CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
remainderLower.startsWith("group:"),
parseTarget: parseBlueBubblesTarget,
});
if (servicePrefixed) {
return servicePrefixed;
}
const chatTarget = parseChatTargetPrefixesOrThrow({
trimmed,
lower,
chatIdPrefixes: CHAT_ID_PREFIXES,
chatGuidPrefixes: CHAT_GUID_PREFIXES,
chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
});
if (chatTarget) {
return chatTarget;
}
const groupTarget = parseGroupTarget({ trimmed, lower, requireValue: true });
if (groupTarget) {
return groupTarget;
}
const rawChatGuid = parseRawChatGuid(trimmed);
if (rawChatGuid) {
return { kind: "chat_guid", chatGuid: rawChatGuid };
}
const rawChatIdentifierTarget = parseRawChatIdentifierTarget(trimmed);
if (rawChatIdentifierTarget) {
return rawChatIdentifierTarget;
}
return { kind: "handle", to: trimmed, service: "auto" };
}
export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget {
const trimmed = normalizeOptionalString(raw) ?? "";
if (!trimmed) {
return { kind: "handle", handle: "" };
}
const lower = normalizeLowercaseStringOrEmpty(trimmed);
const servicePrefixed = resolveServicePrefixedAllowTarget({
trimmed,
lower,
servicePrefixes: SERVICE_PREFIXES,
parseAllowTarget: parseBlueBubblesAllowTarget,
});
if (servicePrefixed) {
return servicePrefixed;
}
const chatTarget = parseChatAllowTargetPrefixes({
trimmed,
lower,
chatIdPrefixes: CHAT_ID_PREFIXES,
chatGuidPrefixes: CHAT_GUID_PREFIXES,
chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
});
if (chatTarget) {
return chatTarget;
}
const groupTarget = parseGroupTarget({ trimmed, lower, requireValue: false });
if (groupTarget) {
return groupTarget;
}
const rawChatIdentifierTarget = parseRawChatIdentifierTarget(trimmed);
if (rawChatIdentifierTarget) {
return rawChatIdentifierTarget;
}
return { kind: "handle", handle: normalizeBlueBubblesHandle(trimmed) };
}
export function isAllowedBlueBubblesSender(params: {
allowFrom: Array<string | number>;
sender: string;
chatId?: number | null;
chatGuid?: string | null;
chatIdentifier?: string | null;
}): boolean {
return isAllowedParsedChatSender({
allowFrom: params.allowFrom,
sender: params.sender,
chatId: params.chatId,
chatGuid: params.chatGuid,
chatIdentifier: params.chatIdentifier,
normalizeSender: normalizeBlueBubblesHandle,
parseAllowTarget: parseBlueBubblesAllowTarget,
});
}
export function formatBlueBubblesChatTarget(params: {
chatId?: number | null;
chatGuid?: string | null;
chatIdentifier?: string | null;
}): string {
if (params.chatId && Number.isFinite(params.chatId)) {
return `chat_id:${params.chatId}`;
}
const guid = normalizeOptionalString(params.chatGuid);
if (guid) {
return `chat_guid:${guid}`;
}
const identifier = normalizeOptionalString(params.chatIdentifier);
if (identifier) {
return `chat_identifier:${identifier}`;
}
return "";
}

View file

@ -0,0 +1,155 @@
import type { Mock } from "vitest";
import { afterEach, beforeEach, vi } from "vitest";
import {
normalizeBlueBubblesAccountsMap,
normalizeBlueBubblesPrivateNetworkAliases,
resolveBlueBubblesEffectiveAllowPrivateNetworkFromConfig,
resolveBlueBubblesPrivateNetworkConfigValue as resolveBlueBubblesPrivateNetworkConfigValueFromConfig,
} from "./accounts-normalization.js";
import { _setFetchGuardForTesting } from "./types.js";
export const BLUE_BUBBLES_PRIVATE_API_STATUS = {
enabled: true,
disabled: false,
unknown: null,
} as const;
type BlueBubblesPrivateApiStatusMock = {
mockReturnValue: (value: boolean | null) => unknown;
mockReturnValueOnce: (value: boolean | null) => unknown;
};
export function mockBlueBubblesPrivateApiStatus(
mock: Pick<BlueBubblesPrivateApiStatusMock, "mockReturnValue">,
value: boolean | null,
) {
mock.mockReturnValue(value);
}
export function mockBlueBubblesPrivateApiStatusOnce(
mock: Pick<BlueBubblesPrivateApiStatusMock, "mockReturnValueOnce">,
value: boolean | null,
) {
mock.mockReturnValueOnce(value);
}
export function resolveBlueBubblesAccountFromConfig(params: {
cfg?: { channels?: { bluebubbles?: Record<string, unknown> } };
accountId?: string;
}) {
const baseConfig =
normalizeBlueBubblesPrivateNetworkAliases(params.cfg?.channels?.bluebubbles ?? {}) ?? {};
const accounts = normalizeBlueBubblesAccountsMap(
baseConfig.accounts as Record<string, Record<string, unknown> | undefined> | undefined,
);
const accountId = params.accountId ?? "default";
const accountConfig =
normalizeBlueBubblesPrivateNetworkAliases(accounts?.[accountId] ?? {}) ?? {};
const config: Record<string, unknown> = {
...baseConfig,
...accountConfig,
network:
typeof baseConfig.network === "object" &&
baseConfig.network &&
!Array.isArray(baseConfig.network) &&
typeof accountConfig.network === "object" &&
accountConfig.network &&
!Array.isArray(accountConfig.network)
? {
...(baseConfig.network as Record<string, unknown>),
...(accountConfig.network as Record<string, unknown>),
}
: (accountConfig.network ?? baseConfig.network),
};
return {
accountId,
enabled: config.enabled !== false,
configured: Boolean(config.serverUrl && config.password),
config,
};
}
export function createBlueBubblesAccountsMockModule() {
return {
resolveBlueBubblesAccount: vi.fn(resolveBlueBubblesAccountFromConfig),
resolveBlueBubblesEffectiveAllowPrivateNetwork: vi.fn(
resolveBlueBubblesEffectiveAllowPrivateNetworkFromConfig,
),
resolveBlueBubblesPrivateNetworkConfigValue: vi.fn(
resolveBlueBubblesPrivateNetworkConfigValueFromConfig,
),
};
}
type BlueBubblesProbeMockModule = {
fetchBlueBubblesServerInfo: Mock<() => Promise<Record<string, unknown> | null>>;
getCachedBlueBubblesPrivateApiStatus: Mock<() => boolean | null>;
isBlueBubblesPrivateApiStatusEnabled: Mock<(status: boolean | null) => boolean>;
isMacOS26OrHigher: Mock<(accountId?: string) => boolean>;
};
export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule {
return {
fetchBlueBubblesServerInfo: vi.fn().mockResolvedValue(null),
getCachedBlueBubblesPrivateApiStatus: vi
.fn()
.mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown),
isBlueBubblesPrivateApiStatusEnabled: vi.fn((status: boolean | null) => status === true),
isMacOS26OrHigher: vi.fn().mockReturnValue(false),
};
}
export function installBlueBubblesFetchTestHooks(params: {
mockFetch: ReturnType<typeof vi.fn>;
privateApiStatusMock: {
mockReset?: () => unknown;
mockClear?: () => unknown;
mockReturnValue: (value: boolean | null) => unknown;
};
}) {
const setFetchGuardPassthrough = createBlueBubblesFetchGuardPassthroughInstaller();
beforeEach(() => {
vi.stubGlobal("fetch", params.mockFetch);
// Replace the SSRF guard with a passthrough that delegates to the mocked global.fetch,
// wrapping the result in a real Response so callers can call .arrayBuffer() on it.
setFetchGuardPassthrough();
params.mockFetch.mockReset();
params.privateApiStatusMock.mockReset?.();
params.privateApiStatusMock.mockClear?.();
params.privateApiStatusMock.mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown);
});
afterEach(() => {
_setFetchGuardForTesting(null);
vi.unstubAllGlobals();
});
}
export function createBlueBubblesFetchGuardPassthroughInstaller() {
return (capturePolicy?: (policy: unknown) => void) => {
_setFetchGuardForTesting(async (params) => {
capturePolicy?.(params.policy);
const raw = await globalThis.fetch(params.url, params.init);
let body: ArrayBuffer;
if (typeof raw.arrayBuffer === "function") {
body = await raw.arrayBuffer();
} else {
const text =
typeof (raw as { text?: () => Promise<string> }).text === "function"
? await (raw as { text: () => Promise<string> }).text()
: typeof (raw as { json?: () => Promise<unknown> }).json === "function"
? JSON.stringify(await (raw as { json: () => Promise<unknown> }).json())
: "";
body = new TextEncoder().encode(text).buffer;
}
return {
response: new Response(body, {
status: (raw as { status?: number }).status ?? 200,
headers: (raw as { headers?: HeadersInit }).headers,
}),
release: async () => {},
finalUrl: params.url,
};
});
};
}

View file

@ -0,0 +1,11 @@
import { vi } from "vitest";
vi.mock("./accounts.js", async () => {
const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js");
return createBlueBubblesAccountsMockModule();
});
vi.mock("./probe.js", async () => {
const { createBlueBubblesProbeMockModule } = await import("./test-harness.js");
return createBlueBubblesProbeMockModule();
});

View file

@ -0,0 +1,133 @@
import type { HistoryEntry, PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
import { vi } from "vitest";
import { createPluginRuntimeMock } from "../../../../test/helpers/plugins/plugin-runtime-mock.js";
import { _resetBlueBubblesInboundDedupForTest } from "../inbound-dedupe.js";
import {
_resetBlueBubblesShortIdState,
clearBlueBubblesWebhookSecurityStateForTest,
} from "../monitor.js";
import { setBlueBubblesRuntime } from "../runtime.js";
type BlueBubblesHistoryFetchResult = {
entries: HistoryEntry[];
resolved: boolean;
};
export type DispatchReplyParams = Parameters<
PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"]
>[0];
export const EMPTY_DISPATCH_RESULT = {
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
} as const;
type BlueBubblesMonitorTestRuntimeMocks = {
enqueueSystemEvent: PluginRuntime["system"]["enqueueSystemEvent"];
chunkMarkdownText: PluginRuntime["channel"]["text"]["chunkMarkdownText"];
chunkByNewline: PluginRuntime["channel"]["text"]["chunkByNewline"];
chunkMarkdownTextWithMode: PluginRuntime["channel"]["text"]["chunkMarkdownTextWithMode"];
chunkTextWithMode: PluginRuntime["channel"]["text"]["chunkTextWithMode"];
resolveChunkMode: PluginRuntime["channel"]["text"]["resolveChunkMode"];
hasControlCommand: PluginRuntime["channel"]["text"]["hasControlCommand"];
dispatchReplyWithBufferedBlockDispatcher: PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"];
formatAgentEnvelope: PluginRuntime["channel"]["reply"]["formatAgentEnvelope"];
formatInboundEnvelope: PluginRuntime["channel"]["reply"]["formatInboundEnvelope"];
resolveEnvelopeFormatOptions: PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"];
resolveAgentRoute: PluginRuntime["channel"]["routing"]["resolveAgentRoute"];
buildPairingReply: PluginRuntime["channel"]["pairing"]["buildPairingReply"];
readAllowFromStore: PluginRuntime["channel"]["pairing"]["readAllowFromStore"];
upsertPairingRequest: PluginRuntime["channel"]["pairing"]["upsertPairingRequest"];
saveMediaBuffer: PluginRuntime["channel"]["media"]["saveMediaBuffer"];
resolveStorePath: PluginRuntime["channel"]["session"]["resolveStorePath"];
readSessionUpdatedAt: PluginRuntime["channel"]["session"]["readSessionUpdatedAt"];
buildMentionRegexes: PluginRuntime["channel"]["mentions"]["buildMentionRegexes"];
matchesMentionPatterns: PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"];
matchesMentionWithExplicit: PluginRuntime["channel"]["mentions"]["matchesMentionWithExplicit"];
resolveGroupPolicy: PluginRuntime["channel"]["groups"]["resolveGroupPolicy"];
resolveRequireMention: PluginRuntime["channel"]["groups"]["resolveRequireMention"];
resolveCommandAuthorizedFromAuthorizers: PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"];
};
export function createBlueBubblesMonitorTestRuntime(
mocks: BlueBubblesMonitorTestRuntimeMocks,
): PluginRuntime {
// Keep this helper small and explicit: BlueBubbles tests should only pay for the
// runtime slices monitor coverage actually consumes, while still tracking contract drift.
return createPluginRuntimeMock({
system: {
enqueueSystemEvent: mocks.enqueueSystemEvent,
},
channel: {
text: {
chunkMarkdownText: mocks.chunkMarkdownText,
chunkByNewline: mocks.chunkByNewline,
chunkMarkdownTextWithMode: mocks.chunkMarkdownTextWithMode,
chunkTextWithMode: mocks.chunkTextWithMode,
resolveChunkMode: mocks.resolveChunkMode,
hasControlCommand: mocks.hasControlCommand,
},
reply: {
dispatchReplyWithBufferedBlockDispatcher: mocks.dispatchReplyWithBufferedBlockDispatcher,
formatAgentEnvelope: mocks.formatAgentEnvelope,
formatInboundEnvelope: mocks.formatInboundEnvelope,
resolveEnvelopeFormatOptions: mocks.resolveEnvelopeFormatOptions,
},
routing: {
resolveAgentRoute: mocks.resolveAgentRoute,
},
pairing: {
buildPairingReply: mocks.buildPairingReply,
readAllowFromStore: mocks.readAllowFromStore,
upsertPairingRequest: mocks.upsertPairingRequest,
},
media: {
saveMediaBuffer: mocks.saveMediaBuffer,
},
session: {
resolveStorePath: mocks.resolveStorePath,
readSessionUpdatedAt: mocks.readSessionUpdatedAt,
},
mentions: {
buildMentionRegexes: mocks.buildMentionRegexes,
matchesMentionPatterns: mocks.matchesMentionPatterns,
matchesMentionWithExplicit: mocks.matchesMentionWithExplicit,
},
groups: {
resolveGroupPolicy: mocks.resolveGroupPolicy,
resolveRequireMention: mocks.resolveRequireMention,
},
commands: {
resolveCommandAuthorizedFromAuthorizers: mocks.resolveCommandAuthorizedFromAuthorizers,
},
},
});
}
export function resetBlueBubblesMonitorTestState(params: {
createRuntime: () => PluginRuntime;
fetchHistoryMock: { mockResolvedValue: (value: BlueBubblesHistoryFetchResult) => unknown };
readAllowFromStoreMock: { mockResolvedValue: (value: string[]) => unknown };
upsertPairingRequestMock: {
mockResolvedValue: (value: { code: string; created: boolean }) => unknown;
};
resolveRequireMentionMock: { mockReturnValue: (value: boolean) => unknown };
hasControlCommandMock: { mockReturnValue: (value: boolean) => unknown };
resolveCommandAuthorizedFromAuthorizersMock: { mockReturnValue: (value: boolean) => unknown };
buildMentionRegexesMock: { mockReturnValue: (value: RegExp[]) => unknown };
extraReset?: () => void;
}) {
vi.clearAllMocks();
_resetBlueBubblesShortIdState();
_resetBlueBubblesInboundDedupForTest();
clearBlueBubblesWebhookSecurityStateForTest();
params.extraReset?.();
params.fetchHistoryMock.mockResolvedValue({ entries: [], resolved: true });
params.readAllowFromStoreMock.mockResolvedValue([]);
params.upsertPairingRequestMock.mockResolvedValue({ code: "TESTCODE", created: true });
params.resolveRequireMentionMock.mockReturnValue(false);
params.hasControlCommandMock.mockReturnValue(false);
params.resolveCommandAuthorizedFromAuthorizersMock.mockReturnValue(false);
params.buildMentionRegexesMock.mockReturnValue([/\bbert\b/i]);
setBlueBubblesRuntime(params.createRuntime());
}

View file

@ -0,0 +1,193 @@
import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/setup";
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
export type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
export type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/setup";
export type BlueBubblesGroupConfig = {
/** If true, only respond in this group when mentioned. */
requireMention?: boolean;
/** Optional tool policy overrides for this group. */
tools?: { allow?: string[]; deny?: string[] };
};
export type BlueBubblesActionConfig = {
reactions?: boolean;
edit?: boolean;
unsend?: boolean;
reply?: boolean;
sendWithEffect?: boolean;
renameGroup?: boolean;
setGroupIcon?: boolean;
addParticipant?: boolean;
removeParticipant?: boolean;
leaveGroup?: boolean;
sendAttachment?: boolean;
};
export type BlueBubblesNetworkConfig = {
/** Dangerous opt-in for same-host or trusted private/internal BlueBubbles deployments. */
dangerouslyAllowPrivateNetwork?: boolean;
};
export type BlueBubblesAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/** If false, do not start this BlueBubbles account. Default: true. */
enabled?: boolean;
/** Base URL for the BlueBubbles API. */
serverUrl?: string;
/** Password for BlueBubbles API authentication. */
password?: string;
/** Webhook path for the gateway HTTP server. */
webhookPath?: string;
/** Direct message access policy (default: pairing). */
dmPolicy?: DmPolicy;
allowFrom?: Array<string | number>;
/** Optional allowlist for group senders. */
groupAllowFrom?: Array<string | number>;
/** Group message handling policy. */
groupPolicy?: GroupPolicy;
/** Enrich unnamed group participants with local macOS Contacts names after gating. Default: true. */
enrichGroupParticipantsFromContacts?: boolean;
/** Max group messages to keep as history context (0 disables). */
historyLimit?: number;
/** Max DM turns to keep as history context. */
dmHistoryLimit?: number;
/** Per-DM config overrides keyed by user ID. */
dms?: Record<string, unknown>;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "newline" (default) splits on every newline; "length" splits by size. */
chunkMode?: "length" | "newline";
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: Record<string, unknown>;
/** Max outbound media size in MB. */
mediaMaxMb?: number;
/**
* Explicit allowlist of local directory roots permitted for outbound media paths.
* Local paths are rejected unless they resolve under one of these roots.
*/
mediaLocalRoots?: string[];
/** Send read receipts for incoming messages (default: true). */
sendReadReceipts?: boolean;
/** Network policy overrides for same-host or trusted private/internal BlueBubbles deployments. */
network?: BlueBubblesNetworkConfig;
/** Per-group configuration keyed by chat GUID or identifier. */
groups?: Record<string, BlueBubblesGroupConfig>;
/** Per-action tool gating (default: true for all). */
actions?: BlueBubblesActionConfig;
/** Channel health monitor overrides for this channel/account. */
healthMonitor?: {
enabled?: boolean;
};
};
export type BlueBubblesConfig = Omit<BlueBubblesAccountConfig, "actions"> & {
/** Optional per-account BlueBubbles configuration (multi-account). */
accounts?: Record<string, BlueBubblesAccountConfig>;
/** Optional default account id when multiple accounts are configured. */
defaultAccount?: string;
/** Per-action tool gating (default: true for all). */
actions?: BlueBubblesActionConfig;
};
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();
}
// Overridable guard for testing; production code uses fetchWithSsrFGuard.
let _fetchGuard = fetchWithSsrFGuard;
/** @internal Replace the SSRF fetch guard in tests. */
export function _setFetchGuardForTesting(impl: typeof fetchWithSsrFGuard | null): void {
_fetchGuard = impl ?? fetchWithSsrFGuard;
}
export async function blueBubblesFetchWithTimeout(
url: string,
init: RequestInit,
timeoutMs = DEFAULT_TIMEOUT_MS,
ssrfPolicy?: SsrFPolicy,
): Promise<Response> {
if (ssrfPolicy !== undefined) {
// Use SSRF-guarded fetch; buffer the body so the dispatcher can be released
// before the caller reads the response (API responses are small JSON payloads).
const { response, release } = await _fetchGuard({
url,
init,
timeoutMs,
policy: ssrfPolicy,
auditContext: "bluebubbles-api",
});
// Null-body status codes per Fetch spec — Response constructor rejects a body for these.
const isNullBody =
response.status === 101 ||
response.status === 204 ||
response.status === 205 ||
response.status === 304;
try {
const bodyBytes = isNullBody ? null : await response.arrayBuffer();
return new Response(bodyBytes, { status: response.status, headers: response.headers });
} finally {
await release();
}
}
// Strip `dispatcher` from init — the SSRF guard may have attached a bundled-undici
// dispatcher that is incompatible with Node 22+'s built-in undici backing globalThis.fetch().
// Passing it through causes a silent TypeError (invalid onRequestStart method).
// The SSRF validation already completed upstream in fetchWithSsrFGuard before calling
// this function as fetchImpl, so stripping the dispatcher does not weaken security. (#64105)
const { dispatcher: _dispatcher, ...safeInit } = (init ?? {}) as RequestInit & {
dispatcher?: unknown;
};
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...safeInit, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}

View file

@ -0,0 +1,10 @@
export {
WEBHOOK_RATE_LIMIT_DEFAULTS,
createFixedWindowRateLimiter,
createWebhookInFlightLimiter,
registerWebhookTargetWithPluginRoute,
readWebhookBodyOrReject,
resolveRequestClientIp,
resolveWebhookTargetWithAuthOrRejectSync,
withResolvedWebhookRequestPipeline,
} from "openclaw/plugin-sdk/webhook-ingress";

View file

@ -0,0 +1,15 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-path";
import type { BlueBubblesAccountConfig } from "./types.js";
export { normalizeWebhookPath };
export const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string {
const raw = normalizeOptionalString(config?.webhookPath);
if (raw) {
return normalizeWebhookPath(raw);
}
return DEFAULT_WEBHOOK_PATH;
}

View file

@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}