mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-11 06:43:16 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
45
openclaw/extensions/bluebubbles/README.md
Normal file
45
openclaw/extensions/bluebubbles/README.md
Normal 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=❤️`
|
||||
10
openclaw/extensions/bluebubbles/api.ts
Normal file
10
openclaw/extensions/bluebubbles/api.ts
Normal 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";
|
||||
1
openclaw/extensions/bluebubbles/channel-config-api.ts
Normal file
1
openclaw/extensions/bluebubbles/channel-config-api.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { BlueBubblesChannelConfigSchema } from "./src/config-schema.js";
|
||||
8
openclaw/extensions/bluebubbles/contract-api.ts
Normal file
8
openclaw/extensions/bluebubbles/contract-api.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
export {
|
||||
__testing as blueBubblesConversationBindingTesting,
|
||||
createBlueBubblesConversationBindingManager,
|
||||
} from "./src/conversation-bindings.js";
|
||||
20
openclaw/extensions/bluebubbles/index.ts
Normal file
20
openclaw/extensions/bluebubbles/index.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
9
openclaw/extensions/bluebubbles/openclaw.plugin.json
Normal file
9
openclaw/extensions/bluebubbles/openclaw.plugin.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"id": "bluebubbles",
|
||||
"channels": ["bluebubbles"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
56
openclaw/extensions/bluebubbles/package.json
Normal file
56
openclaw/extensions/bluebubbles/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
5
openclaw/extensions/bluebubbles/runtime-api.ts
Normal file
5
openclaw/extensions/bluebubbles/runtime-api.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export {
|
||||
resolveBlueBubblesGroupRequireMention,
|
||||
resolveBlueBubblesGroupToolPolicy,
|
||||
} from "./src/group-policy.js";
|
||||
export { setBlueBubblesRuntime } from "./src/runtime.js";
|
||||
5
openclaw/extensions/bluebubbles/secret-contract-api.ts
Normal file
5
openclaw/extensions/bluebubbles/secret-contract-api.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export {
|
||||
channelSecrets,
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
13
openclaw/extensions/bluebubbles/setup-entry.ts
Normal file
13
openclaw/extensions/bluebubbles/setup-entry.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
82
openclaw/extensions/bluebubbles/src/account-resolve.test.ts
Normal file
82
openclaw/extensions/bluebubbles/src/account-resolve.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
62
openclaw/extensions/bluebubbles/src/account-resolve.ts
Normal file
62
openclaw/extensions/bluebubbles/src/account-resolve.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
107
openclaw/extensions/bluebubbles/src/accounts-normalization.ts
Normal file
107
openclaw/extensions/bluebubbles/src/accounts-normalization.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
100
openclaw/extensions/bluebubbles/src/accounts.ts
Normal file
100
openclaw/extensions/bluebubbles/src/accounts.ts
Normal 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);
|
||||
}
|
||||
5
openclaw/extensions/bluebubbles/src/actions-api.ts
Normal file
5
openclaw/extensions/bluebubbles/src/actions-api.ts
Normal 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";
|
||||
19
openclaw/extensions/bluebubbles/src/actions-contract.ts
Normal file
19
openclaw/extensions/bluebubbles/src/actions-contract.ts
Normal 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
|
||||
>;
|
||||
31
openclaw/extensions/bluebubbles/src/actions.runtime.ts
Normal file
31
openclaw/extensions/bluebubbles/src/actions.runtime.ts
Normal 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,
|
||||
};
|
||||
729
openclaw/extensions/bluebubbles/src/actions.test.ts
Normal file
729
openclaw/extensions/bluebubbles/src/actions.test.ts
Normal 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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
482
openclaw/extensions/bluebubbles/src/actions.ts
Normal file
482
openclaw/extensions/bluebubbles/src/actions.ts
Normal 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}.`);
|
||||
},
|
||||
};
|
||||
862
openclaw/extensions/bluebubbles/src/attachments.test.ts
Normal file
862
openclaw/extensions/bluebubbles/src/attachments.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
303
openclaw/extensions/bluebubbles/src/attachments.ts
Normal file
303
openclaw/extensions/bluebubbles/src/attachments.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
1113
openclaw/extensions/bluebubbles/src/catchup.test.ts
Normal file
1113
openclaw/extensions/bluebubbles/src/catchup.test.ts
Normal file
File diff suppressed because it is too large
Load diff
652
openclaw/extensions/bluebubbles/src/catchup.ts
Normal file
652
openclaw/extensions/bluebubbles/src/catchup.ts
Normal 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;
|
||||
}
|
||||
68
openclaw/extensions/bluebubbles/src/channel-shared.ts
Normal file
68
openclaw/extensions/bluebubbles/src/channel-shared.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
48
openclaw/extensions/bluebubbles/src/channel.pairing.test.ts
Normal file
48
openclaw/extensions/bluebubbles/src/channel.pairing.test.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
19
openclaw/extensions/bluebubbles/src/channel.runtime.ts
Normal file
19
openclaw/extensions/bluebubbles/src/channel.runtime.ts
Normal 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,
|
||||
};
|
||||
31
openclaw/extensions/bluebubbles/src/channel.setup.ts
Normal file
31
openclaw/extensions/bluebubbles/src/channel.setup.ts
Normal 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,
|
||||
};
|
||||
80
openclaw/extensions/bluebubbles/src/channel.status.test.ts
Normal file
80
openclaw/extensions/bluebubbles/src/channel.status.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
356
openclaw/extensions/bluebubbles/src/channel.ts
Normal file
356
openclaw/extensions/bluebubbles/src/channel.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
621
openclaw/extensions/bluebubbles/src/chat.test.ts
Normal file
621
openclaw/extensions/bluebubbles/src/chat.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
305
openclaw/extensions/bluebubbles/src/chat.ts
Normal file
305
openclaw/extensions/bluebubbles/src/chat.ts
Normal 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");
|
||||
}
|
||||
633
openclaw/extensions/bluebubbles/src/client.test.ts
Normal file
633
openclaw/extensions/bluebubbles/src/client.test.ts
Normal 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;
|
||||
582
openclaw/extensions/bluebubbles/src/client.ts
Normal file
582
openclaw/extensions/bluebubbles/src/client.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
84
openclaw/extensions/bluebubbles/src/config-apply.ts
Normal file
84
openclaw/extensions/bluebubbles/src/config-apply.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
111
openclaw/extensions/bluebubbles/src/config-schema.ts
Normal file
111
openclaw/extensions/bluebubbles/src/config-schema.ts
Normal 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,
|
||||
});
|
||||
12
openclaw/extensions/bluebubbles/src/config-ui-hints.ts
Normal file
12
openclaw/extensions/bluebubbles/src/config-ui-hints.ts
Normal 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>;
|
||||
|
|
@ -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",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
46
openclaw/extensions/bluebubbles/src/conversation-bindings.ts
Normal file
46
openclaw/extensions/bluebubbles/src/conversation-bindings.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
77
openclaw/extensions/bluebubbles/src/conversation-id.ts
Normal file
77
openclaw/extensions/bluebubbles/src/conversation-id.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
83
openclaw/extensions/bluebubbles/src/conversation-route.ts
Normal file
83
openclaw/extensions/bluebubbles/src/conversation-route.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
9
openclaw/extensions/bluebubbles/src/doctor-contract.ts
Normal file
9
openclaw/extensions/bluebubbles/src/doctor-contract.ts
Normal 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;
|
||||
40
openclaw/extensions/bluebubbles/src/doctor.test.ts
Normal file
40
openclaw/extensions/bluebubbles/src/doctor.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
10
openclaw/extensions/bluebubbles/src/doctor.ts
Normal file
10
openclaw/extensions/bluebubbles/src/doctor.ts
Normal 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,
|
||||
};
|
||||
40
openclaw/extensions/bluebubbles/src/group-policy.ts
Normal file
40
openclaw/extensions/bluebubbles/src/group-policy.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
183
openclaw/extensions/bluebubbles/src/history.ts
Normal file
183
openclaw/extensions/bluebubbles/src/history.ts
Normal 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 };
|
||||
}
|
||||
94
openclaw/extensions/bluebubbles/src/inbound-dedupe.test.ts
Normal file
94
openclaw/extensions/bluebubbles/src/inbound-dedupe.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
232
openclaw/extensions/bluebubbles/src/inbound-dedupe.ts
Normal file
232
openclaw/extensions/bluebubbles/src/inbound-dedupe.ts
Normal 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();
|
||||
}
|
||||
62
openclaw/extensions/bluebubbles/src/local-file-access.ts
Normal file
62
openclaw/extensions/bluebubbles/src/local-file-access.ts
Normal 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;
|
||||
}
|
||||
326
openclaw/extensions/bluebubbles/src/media-send.test.ts
Normal file
326
openclaw/extensions/bluebubbles/src/media-send.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
296
openclaw/extensions/bluebubbles/src/media-send.ts
Normal file
296
openclaw/extensions/bluebubbles/src/media-send.ts
Normal 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;
|
||||
}
|
||||
228
openclaw/extensions/bluebubbles/src/monitor-debounce.ts
Normal file
228
openclaw/extensions/bluebubbles/src/monitor-debounce.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
142
openclaw/extensions/bluebubbles/src/monitor-normalize.test.ts
Normal file
142
openclaw/extensions/bluebubbles/src/monitor-normalize.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
849
openclaw/extensions/bluebubbles/src/monitor-normalize.ts
Normal file
849
openclaw/extensions/bluebubbles/src/monitor-normalize.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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";
|
||||
1889
openclaw/extensions/bluebubbles/src/monitor-processing.ts
Normal file
1889
openclaw/extensions/bluebubbles/src/monitor-processing.ts
Normal file
File diff suppressed because it is too large
Load diff
182
openclaw/extensions/bluebubbles/src/monitor-reply-cache.ts
Normal file
182
openclaw/extensions/bluebubbles/src/monitor-reply-cache.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
123
openclaw/extensions/bluebubbles/src/monitor-self-chat-cache.ts
Normal file
123
openclaw/extensions/bluebubbles/src/monitor-self-chat-cache.ts
Normal 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;
|
||||
}
|
||||
33
openclaw/extensions/bluebubbles/src/monitor-shared.ts
Normal file
33
openclaw/extensions/bluebubbles/src/monitor-shared.ts
Normal 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;
|
||||
};
|
||||
2121
openclaw/extensions/bluebubbles/src/monitor.test.ts
Normal file
2121
openclaw/extensions/bluebubbles/src/monitor.test.ts
Normal file
File diff suppressed because it is too large
Load diff
396
openclaw/extensions/bluebubbles/src/monitor.ts
Normal file
396
openclaw/extensions/bluebubbles/src/monitor.ts
Normal 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 };
|
||||
681
openclaw/extensions/bluebubbles/src/monitor.webhook-auth.test.ts
Normal file
681
openclaw/extensions/bluebubbles/src/monitor.webhook-auth.test.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
57
openclaw/extensions/bluebubbles/src/multipart.ts
Normal file
57
openclaw/extensions/bluebubbles/src/multipart.ts
Normal 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"}`);
|
||||
}
|
||||
37
openclaw/extensions/bluebubbles/src/pairing.ts
Normal file
37
openclaw/extensions/bluebubbles/src/pairing.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
378
openclaw/extensions/bluebubbles/src/participant-contact-names.ts
Normal file
378
openclaw/extensions/bluebubbles/src/participant-contact-names.ts
Normal 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();
|
||||
}
|
||||
174
openclaw/extensions/bluebubbles/src/probe.ts
Normal file
174
openclaw/extensions/bluebubbles/src/probe.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
417
openclaw/extensions/bluebubbles/src/reactions.test.ts
Normal file
417
openclaw/extensions/bluebubbles/src/reactions.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
203
openclaw/extensions/bluebubbles/src/reactions.ts
Normal file
203
openclaw/extensions/bluebubbles/src/reactions.ts
Normal 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"}`);
|
||||
}
|
||||
}
|
||||
1
openclaw/extensions/bluebubbles/src/request-url.ts
Normal file
1
openclaw/extensions/bluebubbles/src/request-url.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { resolveRequestUrl } from "openclaw/plugin-sdk/request-url";
|
||||
61
openclaw/extensions/bluebubbles/src/runtime-api.ts
Normal file
61
openclaw/extensions/bluebubbles/src/runtime-api.ts
Normal 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";
|
||||
32
openclaw/extensions/bluebubbles/src/runtime.ts
Normal file
32
openclaw/extensions/bluebubbles/src/runtime.ts
Normal 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);
|
||||
}
|
||||
59
openclaw/extensions/bluebubbles/src/secret-contract.ts
Normal file
59
openclaw/extensions/bluebubbles/src/secret-contract.ts
Normal 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,
|
||||
};
|
||||
6
openclaw/extensions/bluebubbles/src/secret-input.ts
Normal file
6
openclaw/extensions/bluebubbles/src/secret-input.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export {
|
||||
buildSecretInputSchema,
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
61
openclaw/extensions/bluebubbles/src/send-helpers.ts
Normal file
61
openclaw/extensions/bluebubbles/src/send-helpers.ts
Normal 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";
|
||||
}
|
||||
1495
openclaw/extensions/bluebubbles/src/send.test.ts
Normal file
1495
openclaw/extensions/bluebubbles/src/send.test.ts
Normal file
File diff suppressed because it is too large
Load diff
612
openclaw/extensions/bluebubbles/src/send.ts
Normal file
612
openclaw/extensions/bluebubbles/src/send.ts
Normal 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);
|
||||
}
|
||||
37
openclaw/extensions/bluebubbles/src/session-route.ts
Normal file
37
openclaw/extensions/bluebubbles/src/session-route.ts
Normal 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}`,
|
||||
});
|
||||
}
|
||||
99
openclaw/extensions/bluebubbles/src/setup-core.ts
Normal file
99
openclaw/extensions/bluebubbles/src/setup-core.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
697
openclaw/extensions/bluebubbles/src/setup-surface.test.ts
Normal file
697
openclaw/extensions/bluebubbles/src/setup-surface.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
299
openclaw/extensions/bluebubbles/src/setup-surface.ts
Normal file
299
openclaw/extensions/bluebubbles/src/setup-surface.ts
Normal 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 };
|
||||
55
openclaw/extensions/bluebubbles/src/status-issues.test.ts
Normal file
55
openclaw/extensions/bluebubbles/src/status-issues.test.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
102
openclaw/extensions/bluebubbles/src/status-issues.ts
Normal file
102
openclaw/extensions/bluebubbles/src/status-issues.ts
Normal 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.",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
428
openclaw/extensions/bluebubbles/src/targets.ts
Normal file
428
openclaw/extensions/bluebubbles/src/targets.ts
Normal 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 "";
|
||||
}
|
||||
155
openclaw/extensions/bluebubbles/src/test-harness.ts
Normal file
155
openclaw/extensions/bluebubbles/src/test-harness.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
11
openclaw/extensions/bluebubbles/src/test-mocks.ts
Normal file
11
openclaw/extensions/bluebubbles/src/test-mocks.ts
Normal 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();
|
||||
});
|
||||
|
|
@ -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());
|
||||
}
|
||||
193
openclaw/extensions/bluebubbles/src/types.ts
Normal file
193
openclaw/extensions/bluebubbles/src/types.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
openclaw/extensions/bluebubbles/src/webhook-ingress.ts
Normal file
10
openclaw/extensions/bluebubbles/src/webhook-ingress.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export {
|
||||
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
||||
createFixedWindowRateLimiter,
|
||||
createWebhookInFlightLimiter,
|
||||
registerWebhookTargetWithPluginRoute,
|
||||
readWebhookBodyOrReject,
|
||||
resolveRequestClientIp,
|
||||
resolveWebhookTargetWithAuthOrRejectSync,
|
||||
withResolvedWebhookRequestPipeline,
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
15
openclaw/extensions/bluebubbles/src/webhook-shared.ts
Normal file
15
openclaw/extensions/bluebubbles/src/webhook-shared.ts
Normal 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;
|
||||
}
|
||||
16
openclaw/extensions/bluebubbles/tsconfig.json
Normal file
16
openclaw/extensions/bluebubbles/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue